This commit is contained in:
2025-12-01 17:21:38 +08:00
parent 32fee2b8ab
commit fab8c13cb3
7511 changed files with 996300 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
export const DEFAULT_SORT = {
sortBy: 'install_count',
sortOrder: 'DESC',
}

View File

@@ -0,0 +1,347 @@
'use client'
import type {
ReactNode,
} from 'react'
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import {
createContext,
useContextSelector,
} from 'use-context-selector'
import { PLUGIN_TYPE_SEARCH_MAP } from './plugin-type-switch'
import type { Plugin } from '../types'
import {
getValidCategoryKeys,
getValidTagKeys,
} from '../utils'
import type {
MarketplaceCollection,
PluginsSort,
SearchParams,
SearchParamsFromCollection,
} from './types'
import { DEFAULT_SORT } from './constants'
import {
useMarketplaceCollectionsAndPlugins,
useMarketplaceContainerScroll,
useMarketplacePlugins,
} from './hooks'
import {
getMarketplaceListCondition,
getMarketplaceListFilterType,
updateSearchParams,
} from './utils'
import { useInstalledPluginList } from '@/service/use-plugins'
import { debounce, noop } from 'lodash-es'
export type MarketplaceContextValue = {
intersected: boolean
setIntersected: (intersected: boolean) => void
searchPluginText: string
handleSearchPluginTextChange: (text: string) => void
filterPluginTags: string[]
handleFilterPluginTagsChange: (tags: string[]) => void
activePluginType: string
handleActivePluginTypeChange: (type: string) => void
page: number
handlePageChange: (page: number) => void
plugins?: Plugin[]
pluginsTotal?: number
resetPlugins: () => void
sort: PluginsSort
handleSortChange: (sort: PluginsSort) => void
handleQueryPlugins: () => void
handleMoreClick: (searchParams: SearchParamsFromCollection) => void
marketplaceCollectionsFromClient?: MarketplaceCollection[]
setMarketplaceCollectionsFromClient: (collections: MarketplaceCollection[]) => void
marketplaceCollectionPluginsMapFromClient?: Record<string, Plugin[]>
setMarketplaceCollectionPluginsMapFromClient: (map: Record<string, Plugin[]>) => void
isLoading: boolean
isSuccessCollections: boolean
}
export const MarketplaceContext = createContext<MarketplaceContextValue>({
intersected: true,
setIntersected: noop,
searchPluginText: '',
handleSearchPluginTextChange: noop,
filterPluginTags: [],
handleFilterPluginTagsChange: noop,
activePluginType: 'all',
handleActivePluginTypeChange: noop,
page: 1,
handlePageChange: noop,
plugins: undefined,
pluginsTotal: 0,
resetPlugins: noop,
sort: DEFAULT_SORT,
handleSortChange: noop,
handleQueryPlugins: noop,
handleMoreClick: noop,
marketplaceCollectionsFromClient: [],
setMarketplaceCollectionsFromClient: noop,
marketplaceCollectionPluginsMapFromClient: {},
setMarketplaceCollectionPluginsMapFromClient: noop,
isLoading: false,
isSuccessCollections: false,
})
type MarketplaceContextProviderProps = {
children: ReactNode
searchParams?: SearchParams
shouldExclude?: boolean
scrollContainerId?: string
showSearchParams?: boolean
}
export function useMarketplaceContext(selector: (value: MarketplaceContextValue) => any) {
return useContextSelector(MarketplaceContext, selector)
}
export const MarketplaceContextProvider = ({
children,
searchParams,
shouldExclude,
scrollContainerId,
showSearchParams,
}: MarketplaceContextProviderProps) => {
const { data, isSuccess } = useInstalledPluginList(!shouldExclude)
const exclude = useMemo(() => {
if (shouldExclude)
return data?.plugins.map(plugin => plugin.plugin_id)
}, [data?.plugins, shouldExclude])
const queryFromSearchParams = searchParams?.q || ''
const tagsFromSearchParams = searchParams?.tags ? getValidTagKeys(searchParams.tags.split(',')) : []
const hasValidTags = !!tagsFromSearchParams.length
const hasValidCategory = getValidCategoryKeys(searchParams?.category)
const categoryFromSearchParams = hasValidCategory || PLUGIN_TYPE_SEARCH_MAP.all
const [intersected, setIntersected] = useState(true)
const [searchPluginText, setSearchPluginText] = useState(queryFromSearchParams)
const searchPluginTextRef = useRef(searchPluginText)
const [filterPluginTags, setFilterPluginTags] = useState<string[]>(tagsFromSearchParams)
const filterPluginTagsRef = useRef(filterPluginTags)
const [activePluginType, setActivePluginType] = useState(categoryFromSearchParams)
const activePluginTypeRef = useRef(activePluginType)
const [page, setPage] = useState(1)
const pageRef = useRef(page)
const [sort, setSort] = useState(DEFAULT_SORT)
const sortRef = useRef(sort)
const {
marketplaceCollections: marketplaceCollectionsFromClient,
setMarketplaceCollections: setMarketplaceCollectionsFromClient,
marketplaceCollectionPluginsMap: marketplaceCollectionPluginsMapFromClient,
setMarketplaceCollectionPluginsMap: setMarketplaceCollectionPluginsMapFromClient,
queryMarketplaceCollectionsAndPlugins,
isLoading,
isSuccess: isSuccessCollections,
} = useMarketplaceCollectionsAndPlugins()
const {
plugins,
total: pluginsTotal,
resetPlugins,
queryPlugins,
queryPluginsWithDebounced,
cancelQueryPluginsWithDebounced,
isLoading: isPluginsLoading,
} = useMarketplacePlugins()
useEffect(() => {
if (queryFromSearchParams || hasValidTags || hasValidCategory) {
queryPlugins({
query: queryFromSearchParams,
category: hasValidCategory,
tags: hasValidTags ? tagsFromSearchParams : [],
sortBy: sortRef.current.sortBy,
sortOrder: sortRef.current.sortOrder,
type: getMarketplaceListFilterType(activePluginTypeRef.current),
page: pageRef.current,
})
const url = new URL(window.location.href)
if (searchParams?.language)
url.searchParams.set('language', searchParams?.language)
history.replaceState({}, '', url)
}
else {
if (shouldExclude && isSuccess) {
queryMarketplaceCollectionsAndPlugins({
exclude,
type: getMarketplaceListFilterType(activePluginTypeRef.current),
})
}
}
}, [queryPlugins, queryMarketplaceCollectionsAndPlugins, isSuccess, exclude])
const handleQueryMarketplaceCollectionsAndPlugins = useCallback(() => {
queryMarketplaceCollectionsAndPlugins({
category: activePluginTypeRef.current === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginTypeRef.current,
condition: getMarketplaceListCondition(activePluginTypeRef.current),
exclude,
type: getMarketplaceListFilterType(activePluginTypeRef.current),
})
resetPlugins()
}, [exclude, queryMarketplaceCollectionsAndPlugins, resetPlugins])
const debouncedUpdateSearchParams = useMemo(() => debounce(() => {
updateSearchParams({
query: searchPluginTextRef.current,
category: activePluginTypeRef.current,
tags: filterPluginTagsRef.current,
})
}, 500), [])
const handleUpdateSearchParams = useCallback((debounced?: boolean) => {
if (!showSearchParams)
return
if (debounced) {
debouncedUpdateSearchParams()
}
else {
updateSearchParams({
query: searchPluginTextRef.current,
category: activePluginTypeRef.current,
tags: filterPluginTagsRef.current,
})
}
}, [debouncedUpdateSearchParams, showSearchParams])
const handleQueryPlugins = useCallback((debounced?: boolean) => {
handleUpdateSearchParams(debounced)
if (debounced) {
queryPluginsWithDebounced({
query: searchPluginTextRef.current,
category: activePluginTypeRef.current === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginTypeRef.current,
tags: filterPluginTagsRef.current,
sortBy: sortRef.current.sortBy,
sortOrder: sortRef.current.sortOrder,
exclude,
type: getMarketplaceListFilterType(activePluginTypeRef.current),
page: pageRef.current,
})
}
else {
queryPlugins({
query: searchPluginTextRef.current,
category: activePluginTypeRef.current === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginTypeRef.current,
tags: filterPluginTagsRef.current,
sortBy: sortRef.current.sortBy,
sortOrder: sortRef.current.sortOrder,
exclude,
type: getMarketplaceListFilterType(activePluginTypeRef.current),
page: pageRef.current,
})
}
}, [exclude, queryPluginsWithDebounced, queryPlugins, handleUpdateSearchParams])
const handleQuery = useCallback((debounced?: boolean) => {
if (!searchPluginTextRef.current && !filterPluginTagsRef.current.length) {
handleUpdateSearchParams(debounced)
cancelQueryPluginsWithDebounced()
handleQueryMarketplaceCollectionsAndPlugins()
return
}
handleQueryPlugins(debounced)
}, [handleQueryMarketplaceCollectionsAndPlugins, handleQueryPlugins, cancelQueryPluginsWithDebounced, handleUpdateSearchParams])
const handleSearchPluginTextChange = useCallback((text: string) => {
setSearchPluginText(text)
searchPluginTextRef.current = text
setPage(1)
pageRef.current = 1
handleQuery(true)
}, [handleQuery])
const handleFilterPluginTagsChange = useCallback((tags: string[]) => {
setFilterPluginTags(tags)
filterPluginTagsRef.current = tags
setPage(1)
pageRef.current = 1
handleQuery()
}, [handleQuery])
const handleActivePluginTypeChange = useCallback((type: string) => {
setActivePluginType(type)
activePluginTypeRef.current = type
setPage(1)
pageRef.current = 1
handleQuery()
}, [handleQuery])
const handleSortChange = useCallback((sort: PluginsSort) => {
setSort(sort)
sortRef.current = sort
setPage(1)
pageRef.current = 1
handleQueryPlugins()
}, [handleQueryPlugins])
const handlePageChange = useCallback(() => {
if (pluginsTotal && plugins && pluginsTotal > plugins.length) {
setPage(pageRef.current + 1)
pageRef.current++
handleQueryPlugins()
}
}, [handleQueryPlugins, plugins, pluginsTotal])
const handleMoreClick = useCallback((searchParams: SearchParamsFromCollection) => {
setSearchPluginText(searchParams?.query || '')
searchPluginTextRef.current = searchParams?.query || ''
setSort({
sortBy: searchParams?.sort_by || DEFAULT_SORT.sortBy,
sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder,
})
sortRef.current = {
sortBy: searchParams?.sort_by || DEFAULT_SORT.sortBy,
sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder,
}
setPage(1)
pageRef.current = 1
handleQueryPlugins()
}, [handleQueryPlugins])
useMarketplaceContainerScroll(handlePageChange, scrollContainerId)
return (
<MarketplaceContext.Provider
value={{
intersected,
setIntersected,
searchPluginText,
handleSearchPluginTextChange,
filterPluginTags,
handleFilterPluginTagsChange,
activePluginType,
handleActivePluginTypeChange,
page,
handlePageChange,
plugins,
pluginsTotal,
resetPlugins,
sort,
handleSortChange,
handleQueryPlugins,
handleMoreClick,
marketplaceCollectionsFromClient,
setMarketplaceCollectionsFromClient,
marketplaceCollectionPluginsMapFromClient,
setMarketplaceCollectionPluginsMapFromClient,
isLoading: isLoading || isPluginsLoading,
isSuccessCollections,
}}
>
{children}
</MarketplaceContext.Provider>
)
}

View File

@@ -0,0 +1,79 @@
import {
getLocaleOnServer,
useTranslation as translate,
} from '@/i18n-config/server'
type DescriptionProps = {
locale?: string
}
const Description = async ({
locale: localeFromProps,
}: DescriptionProps) => {
const localeDefault = await getLocaleOnServer()
const { t } = await translate(localeFromProps || localeDefault, 'plugin')
const { t: tCommon } = await translate(localeFromProps || localeDefault, 'common')
const isZhHans = localeFromProps === 'zh-Hans'
return (
<>
<h1 className='title-4xl-semi-bold mb-2 shrink-0 text-center text-text-primary'>
{t('marketplace.empower')}
</h1>
<h2 className='body-md-regular flex shrink-0 items-center justify-center text-center text-text-tertiary'>
{
isZhHans && (
<>
<span className='mr-1'>{tCommon('operation.in')}</span>
{t('marketplace.difyMarketplace')}
{t('marketplace.discover')}
</>
)
}
{
!isZhHans && (
<>
{t('marketplace.discover')}
</>
)
}
<span className="body-md-medium relative z-[1] ml-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
{t('category.models')}
</span>
,
<span className="body-md-medium relative z-[1] ml-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
{t('category.tools')}
</span>
,
<span className="body-md-medium relative z-[1] ml-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
{t('category.datasources')}
</span>
,
<span className="body-md-medium relative z-[1] ml-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
{t('category.triggers')}
</span>
,
<span className="body-md-medium relative z-[1] ml-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
{t('category.agents')}
</span>
,
<span className="body-md-medium relative z-[1] ml-1 mr-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
{t('category.extensions')}
</span>
{t('marketplace.and')}
<span className="body-md-medium relative z-[1] ml-1 mr-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
{t('category.bundles')}
</span>
{
!isZhHans && (
<>
<span className='mr-1'>{tCommon('operation.in')}</span>
{t('marketplace.difyMarketplace')}
</>
)
}
</h2>
</>
)
}
export default Description

View File

@@ -0,0 +1,63 @@
'use client'
import { Group } from '@/app/components/base/icons/src/vender/other'
import Line from './line'
import cn from '@/utils/classnames'
import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks'
type Props = {
text?: string
lightCard?: boolean
className?: string
locale?: string
}
const Empty = ({
text,
lightCard,
className,
locale,
}: Props) => {
const { t } = useMixedTranslation(locale)
return (
<div
className={cn('relative flex h-0 grow flex-wrap overflow-hidden p-2', className)}
>
{
Array.from({ length: 16 }).map((_, index) => (
<div
key={index}
className={cn(
'mb-3 mr-3 h-[144px] w-[calc((100%-36px)/4)] rounded-xl bg-background-section-burn',
index % 4 === 3 && 'mr-0',
index > 11 && 'mb-0',
lightCard && 'bg-background-default-lighter opacity-75',
)}
>
</div>
))
}
{
!lightCard && (
<div
className='absolute inset-0 z-[1] bg-marketplace-plugin-empty'
></div>
)
}
<div className='absolute left-1/2 top-1/2 z-[2] flex -translate-x-1/2 -translate-y-1/2 flex-col items-center'>
<div className='relative mb-3 flex h-14 w-14 items-center justify-center rounded-xl border border-dashed border-divider-deep bg-components-card-bg shadow-lg'>
<Group className='h-5 w-5 text-text-primary' />
<Line className='absolute right-[-1px] top-1/2 -translate-y-1/2' />
<Line className='absolute left-[-1px] top-1/2 -translate-y-1/2' />
<Line className='absolute left-1/2 top-0 -translate-x-1/2 -translate-y-1/2 rotate-90' />
<Line className='absolute left-1/2 top-full -translate-x-1/2 -translate-y-1/2 rotate-90' />
</div>
<div className='system-md-regular text-center text-text-tertiary'>
{text || t('plugin.marketplace.noPluginFound')}
</div>
</div>
</div>
)
}
export default Empty

View File

@@ -0,0 +1,43 @@
'use client'
import useTheme from '@/hooks/use-theme'
type LineProps = {
className?: string
}
const Line = ({
className,
}: LineProps) => {
const { theme } = useTheme()
const isDarkMode = theme === 'dark'
if (isDarkMode) {
return (
<svg xmlns='http://www.w3.org/2000/svg' width='2' height='240' viewBox='0 0 2 240' fill='none' className={className}>
<path d='M1 0L1 240' stroke='url(#paint0_linear_6295_52176)' />
<defs>
<linearGradient id='paint0_linear_6295_52176' x1='-7.99584' y1='240' x2='-7.88094' y2='3.95539e-05' gradientUnits='userSpaceOnUse'>
<stop stopOpacity='0.01' />
<stop offset='0.503965' stopColor='#C8CEDA' stopOpacity='0.14' />
<stop offset='1' stopOpacity='0.01' />
</linearGradient>
</defs>
</svg>
)
}
return (
<svg xmlns='http://www.w3.org/2000/svg' width='2' height='241' viewBox='0 0 2 241' fill='none' className={className}>
<path d='M1 0.5L1 240.5' stroke='url(#paint0_linear_1989_74474)' />
<defs>
<linearGradient id='paint0_linear_1989_74474' x1='-7.99584' y1='240.5' x2='-7.88094' y2='0.50004' gradientUnits='userSpaceOnUse'>
<stop stopColor='white' stopOpacity='0.01' />
<stop offset='0.503965' stopColor='#101828' stopOpacity='0.08' />
<stop offset='1' stopColor='white' stopOpacity='0.01' />
</linearGradient>
</defs>
</svg>
)
}
export default Line

View File

@@ -0,0 +1,179 @@
import {
useCallback,
useEffect,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { useDebounceFn } from 'ahooks'
import type {
Plugin,
} from '../types'
import type {
CollectionsAndPluginsSearchParams,
MarketplaceCollection,
PluginsSearchParams,
} from './types'
import {
getFormattedPlugin,
getMarketplaceCollectionsAndPlugins,
} from './utils'
import i18n from '@/i18n-config/i18next-config'
import {
useMutationPluginsFromMarketplace,
} from '@/service/use-plugins'
export const useMarketplaceCollectionsAndPlugins = () => {
const [isLoading, setIsLoading] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const [marketplaceCollections, setMarketplaceCollections] = useState<MarketplaceCollection[]>()
const [marketplaceCollectionPluginsMap, setMarketplaceCollectionPluginsMap] = useState<Record<string, Plugin[]>>()
const queryMarketplaceCollectionsAndPlugins = useCallback(async (query?: CollectionsAndPluginsSearchParams) => {
try {
setIsLoading(true)
setIsSuccess(false)
const { marketplaceCollections, marketplaceCollectionPluginsMap } = await getMarketplaceCollectionsAndPlugins(query)
setIsLoading(false)
setIsSuccess(true)
setMarketplaceCollections(marketplaceCollections)
setMarketplaceCollectionPluginsMap(marketplaceCollectionPluginsMap)
}
// eslint-disable-next-line unused-imports/no-unused-vars
catch (e) {
setIsLoading(false)
setIsSuccess(false)
}
}, [])
return {
marketplaceCollections,
setMarketplaceCollections,
marketplaceCollectionPluginsMap,
setMarketplaceCollectionPluginsMap,
queryMarketplaceCollectionsAndPlugins,
isLoading,
isSuccess,
}
}
export const useMarketplacePlugins = () => {
const {
data,
mutateAsync,
reset,
isPending,
} = useMutationPluginsFromMarketplace()
const [prevPlugins, setPrevPlugins] = useState<Plugin[] | undefined>()
const resetPlugins = useCallback(() => {
reset()
setPrevPlugins(undefined)
}, [reset])
const handleUpdatePlugins = useCallback((pluginsSearchParams: PluginsSearchParams) => {
mutateAsync(pluginsSearchParams).then((res) => {
const currentPage = pluginsSearchParams.page || 1
const resPlugins = res.data.bundles || res.data.plugins
if (currentPage > 1) {
setPrevPlugins(prevPlugins => [...(prevPlugins || []), ...resPlugins.map((plugin) => {
return getFormattedPlugin(plugin)
})])
}
else {
setPrevPlugins(resPlugins.map((plugin) => {
return getFormattedPlugin(plugin)
}))
}
})
}, [mutateAsync])
const { run: queryPluginsWithDebounced, cancel: cancelQueryPluginsWithDebounced } = useDebounceFn((pluginsSearchParams: PluginsSearchParams) => {
handleUpdatePlugins(pluginsSearchParams)
}, {
wait: 500,
})
return {
plugins: prevPlugins,
total: data?.data?.total,
resetPlugins,
queryPlugins: handleUpdatePlugins,
queryPluginsWithDebounced,
cancelQueryPluginsWithDebounced,
isLoading: isPending,
}
}
/**
* ! Support zh-Hans, pt-BR, ja-JP and en-US for Marketplace page
* ! For other languages, use en-US as fallback
*/
export const useMixedTranslation = (localeFromOuter?: string) => {
let t = useTranslation().t
if (localeFromOuter)
t = i18n.getFixedT(localeFromOuter)
return {
t,
}
}
export const useMarketplaceContainerScroll = (
callback: () => void,
scrollContainerId = 'marketplace-container',
) => {
const handleScroll = useCallback((e: Event) => {
const target = e.target as HTMLDivElement
const {
scrollTop,
scrollHeight,
clientHeight,
} = target
if (scrollTop + clientHeight >= scrollHeight - 5 && scrollTop > 0)
callback()
}, [callback])
useEffect(() => {
const container = document.getElementById(scrollContainerId)
if (container)
container.addEventListener('scroll', handleScroll)
return () => {
if (container)
container.removeEventListener('scroll', handleScroll)
}
}, [handleScroll])
}
export const useSearchBoxAutoAnimate = (searchBoxAutoAnimate?: boolean) => {
const [searchBoxCanAnimate, setSearchBoxCanAnimate] = useState(true)
const handleSearchBoxCanAnimateChange = useCallback(() => {
if (!searchBoxAutoAnimate) {
const clientWidth = document.documentElement.clientWidth
if (clientWidth < 1400)
setSearchBoxCanAnimate(false)
else
setSearchBoxCanAnimate(true)
}
}, [searchBoxAutoAnimate])
useEffect(() => {
handleSearchBoxCanAnimateChange()
}, [handleSearchBoxCanAnimateChange])
useEffect(() => {
window.addEventListener('resize', handleSearchBoxCanAnimateChange)
return () => {
window.removeEventListener('resize', handleSearchBoxCanAnimateChange)
}
}, [handleSearchBoxCanAnimateChange])
return {
searchBoxCanAnimate,
}
}

View File

@@ -0,0 +1,72 @@
import { MarketplaceContextProvider } from './context'
import Description from './description'
import IntersectionLine from './intersection-line'
import SearchBoxWrapper from './search-box/search-box-wrapper'
import PluginTypeSwitch from './plugin-type-switch'
import ListWrapper from './list/list-wrapper'
import type { SearchParams } from './types'
import { getMarketplaceCollectionsAndPlugins } from './utils'
import { TanstackQueryInitializer } from '@/context/query-client'
type MarketplaceProps = {
locale: string
searchBoxAutoAnimate?: boolean
showInstallButton?: boolean
shouldExclude?: boolean
searchParams?: SearchParams
pluginTypeSwitchClassName?: string
intersectionContainerId?: string
scrollContainerId?: string
showSearchParams?: boolean
}
const Marketplace = async ({
locale,
searchBoxAutoAnimate = true,
showInstallButton = true,
shouldExclude,
searchParams,
pluginTypeSwitchClassName,
intersectionContainerId,
scrollContainerId,
showSearchParams = true,
}: MarketplaceProps) => {
let marketplaceCollections: any = []
let marketplaceCollectionPluginsMap = {}
if (!shouldExclude) {
const marketplaceCollectionsAndPluginsData = await getMarketplaceCollectionsAndPlugins()
marketplaceCollections = marketplaceCollectionsAndPluginsData.marketplaceCollections
marketplaceCollectionPluginsMap = marketplaceCollectionsAndPluginsData.marketplaceCollectionPluginsMap
}
return (
<TanstackQueryInitializer>
<MarketplaceContextProvider
searchParams={searchParams}
shouldExclude={shouldExclude}
scrollContainerId={scrollContainerId}
showSearchParams={showSearchParams}
>
<Description locale={locale} />
<IntersectionLine intersectionContainerId={intersectionContainerId} />
<SearchBoxWrapper
locale={locale}
searchBoxAutoAnimate={searchBoxAutoAnimate}
/>
<PluginTypeSwitch
locale={locale}
className={pluginTypeSwitchClassName}
searchBoxAutoAnimate={searchBoxAutoAnimate}
showSearchParams={showSearchParams}
/>
<ListWrapper
locale={locale}
marketplaceCollections={marketplaceCollections}
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap}
showInstallButton={showInstallButton}
/>
</MarketplaceContextProvider>
</TanstackQueryInitializer>
)
}
export default Marketplace

View File

@@ -0,0 +1,30 @@
import { useEffect } from 'react'
import { useMarketplaceContext } from '@/app/components/plugins/marketplace/context'
export const useScrollIntersection = (
anchorRef: React.RefObject<HTMLDivElement | null>,
intersectionContainerId = 'marketplace-container',
) => {
const intersected = useMarketplaceContext(v => v.intersected)
const setIntersected = useMarketplaceContext(v => v.setIntersected)
useEffect(() => {
const container = document.getElementById(intersectionContainerId)
let observer: IntersectionObserver | undefined
if (container && anchorRef.current) {
observer = new IntersectionObserver((entries) => {
const isIntersecting = entries[0].isIntersecting
if (isIntersecting && !intersected)
setIntersected(true)
if (!isIntersecting && intersected)
setIntersected(false)
}, {
root: container,
})
observer.observe(anchorRef.current)
}
return () => observer?.disconnect()
}, [anchorRef, intersected, setIntersected, intersectionContainerId])
}

View File

@@ -0,0 +1,21 @@
'use client'
import { useRef } from 'react'
import { useScrollIntersection } from './hooks'
type IntersectionLineProps = {
intersectionContainerId?: string
}
const IntersectionLine = ({
intersectionContainerId,
}: IntersectionLineProps) => {
const ref = useRef<HTMLDivElement>(null)
useScrollIntersection(ref, intersectionContainerId)
return (
<div ref={ref} className='mb-4 h-px shrink-0 bg-transparent'></div>
)
}
export default IntersectionLine

View File

@@ -0,0 +1,103 @@
'use client'
import { useTheme } from 'next-themes'
import { RiArrowRightUpLine } from '@remixicon/react'
import { getPluginDetailLinkInMarketplace, getPluginLinkInMarketplace } from '../utils'
import Card from '@/app/components/plugins/card'
import CardMoreInfo from '@/app/components/plugins/card/card-more-info'
import type { Plugin } from '@/app/components/plugins/types'
import Button from '@/app/components/base/button'
import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks'
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
import { useBoolean } from 'ahooks'
import { useI18N } from '@/context/i18n'
import { useTags } from '@/app/components/plugins/hooks'
type CardWrapperProps = {
plugin: Plugin
showInstallButton?: boolean
locale?: string
}
const CardWrapper = ({
plugin,
showInstallButton,
locale,
}: CardWrapperProps) => {
const { t } = useMixedTranslation(locale)
const { theme } = useTheme()
const [isShowInstallFromMarketplace, {
setTrue: showInstallFromMarketplace,
setFalse: hideInstallFromMarketplace,
}] = useBoolean(false)
const { locale: localeFromLocale } = useI18N()
const { getTagLabel } = useTags(t)
if (showInstallButton) {
return (
<div
className='group relative cursor-pointer rounded-xl hover:bg-components-panel-on-panel-item-bg-hover'
>
<Card
key={plugin.name}
payload={plugin}
locale={locale}
footer={
<CardMoreInfo
downloadCount={plugin.install_count}
tags={plugin.tags.map(tag => getTagLabel(tag.name))}
/>
}
/>
{
<div className='absolute bottom-0 hidden w-full items-center space-x-2 rounded-b-xl bg-gradient-to-tr from-components-panel-on-panel-item-bg to-background-gradient-mask-transparent px-4 pb-4 pt-8 group-hover:flex'>
<Button
variant='primary'
className='w-[calc(50%-4px)]'
onClick={showInstallFromMarketplace}
>
{t('plugin.detailPanel.operation.install')}
</Button>
<a href={getPluginLinkInMarketplace(plugin, { language: localeFromLocale, theme })} target='_blank' className='block w-[calc(50%-4px)] flex-1 shrink-0'>
<Button
className='w-full gap-0.5'
>
{t('plugin.detailPanel.operation.detail')}
<RiArrowRightUpLine className='ml-1 h-4 w-4' />
</Button>
</a>
</div>
}
{
isShowInstallFromMarketplace && (
<InstallFromMarketplace
manifest={plugin}
uniqueIdentifier={plugin.latest_package_identifier}
onClose={hideInstallFromMarketplace}
onSuccess={hideInstallFromMarketplace}
/>
)
}
</div>
)
}
return (
<a
className='group relative inline-block cursor-pointer rounded-xl'
href={getPluginDetailLinkInMarketplace(plugin)}
>
<Card
key={plugin.name}
payload={plugin}
locale={locale}
footer={
<CardMoreInfo
downloadCount={plugin.install_count}
tags={plugin.tags.map(tag => getTagLabel(tag.name))}
/>
}
/>
</a>
)
}
export default CardWrapper

View File

@@ -0,0 +1,79 @@
'use client'
import type { Plugin } from '../../types'
import type { MarketplaceCollection } from '../types'
import ListWithCollection from './list-with-collection'
import CardWrapper from './card-wrapper'
import Empty from '../empty'
import cn from '@/utils/classnames'
type ListProps = {
marketplaceCollections: MarketplaceCollection[]
marketplaceCollectionPluginsMap: Record<string, Plugin[]>
plugins?: Plugin[]
showInstallButton?: boolean
locale: string
cardContainerClassName?: string
cardRender?: (plugin: Plugin) => React.JSX.Element | null
onMoreClick?: () => void
emptyClassName?: string
}
const List = ({
marketplaceCollections,
marketplaceCollectionPluginsMap,
plugins,
showInstallButton,
locale,
cardContainerClassName,
cardRender,
onMoreClick,
emptyClassName,
}: ListProps) => {
return (
<>
{
!plugins && (
<ListWithCollection
marketplaceCollections={marketplaceCollections}
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap}
showInstallButton={showInstallButton}
locale={locale}
cardContainerClassName={cardContainerClassName}
cardRender={cardRender}
onMoreClick={onMoreClick}
/>
)
}
{
plugins && !!plugins.length && (
<div className={cn(
'grid grid-cols-4 gap-3',
cardContainerClassName,
)}>
{
plugins.map((plugin) => {
if (cardRender)
return cardRender(plugin)
return (
<CardWrapper
key={`${plugin.org}/${plugin.name}`}
plugin={plugin}
showInstallButton={showInstallButton}
locale={locale}
/>
)
})
}
</div>
)
}
{
plugins && !plugins.length && (
<Empty className={emptyClassName} locale={locale} />
)
}
</>
)
}
export default List

View File

@@ -0,0 +1,86 @@
'use client'
import { RiArrowRightSLine } from '@remixicon/react'
import type { MarketplaceCollection } from '../types'
import CardWrapper from './card-wrapper'
import type { Plugin } from '@/app/components/plugins/types'
import { getLanguage } from '@/i18n-config/language'
import cn from '@/utils/classnames'
import type { SearchParamsFromCollection } from '@/app/components/plugins/marketplace/types'
import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks'
type ListWithCollectionProps = {
marketplaceCollections: MarketplaceCollection[]
marketplaceCollectionPluginsMap: Record<string, Plugin[]>
showInstallButton?: boolean
locale: string
cardContainerClassName?: string
cardRender?: (plugin: Plugin) => React.JSX.Element | null
onMoreClick?: (searchParams?: SearchParamsFromCollection) => void
}
const ListWithCollection = ({
marketplaceCollections,
marketplaceCollectionPluginsMap,
showInstallButton,
locale,
cardContainerClassName,
cardRender,
onMoreClick,
}: ListWithCollectionProps) => {
const { t } = useMixedTranslation(locale)
return (
<>
{
marketplaceCollections.filter((collection) => {
return marketplaceCollectionPluginsMap[collection.name]?.length
}).map(collection => (
<div
key={collection.name}
className='py-3'
>
<div className='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>
{
collection.searchable && onMoreClick && (
<div
className='system-xs-medium flex cursor-pointer items-center text-text-accent '
onClick={() => onMoreClick?.(collection.search_params)}
>
{t('plugin.marketplace.viewMore')}
<RiArrowRightSLine className='h-4 w-4' />
</div>
)
}
</div>
<div className={cn(
'mt-2 grid grid-cols-4 gap-3',
cardContainerClassName,
)}>
{
marketplaceCollectionPluginsMap[collection.name].map((plugin) => {
if (cardRender)
return cardRender(plugin)
return (
<CardWrapper
key={plugin.plugin_id}
plugin={plugin}
showInstallButton={showInstallButton}
locale={locale}
/>
)
})
}
</div>
</div>
))
}
</>
)
}
export default ListWithCollection

View File

@@ -0,0 +1,75 @@
'use client'
import { useEffect } from 'react'
import type { Plugin } from '../../types'
import type { MarketplaceCollection } from '../types'
import { useMarketplaceContext } from '../context'
import List from './index'
import SortDropdown from '../sort-dropdown'
import Loading from '@/app/components/base/loading'
import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks'
type ListWrapperProps = {
marketplaceCollections: MarketplaceCollection[]
marketplaceCollectionPluginsMap: Record<string, Plugin[]>
showInstallButton?: boolean
locale: string
}
const ListWrapper = ({
marketplaceCollections,
marketplaceCollectionPluginsMap,
showInstallButton,
locale,
}: ListWrapperProps) => {
const { t } = useMixedTranslation(locale)
const plugins = useMarketplaceContext(v => v.plugins)
const pluginsTotal = useMarketplaceContext(v => v.pluginsTotal)
const marketplaceCollectionsFromClient = useMarketplaceContext(v => v.marketplaceCollectionsFromClient)
const marketplaceCollectionPluginsMapFromClient = useMarketplaceContext(v => v.marketplaceCollectionPluginsMapFromClient)
const isLoading = useMarketplaceContext(v => v.isLoading)
const isSuccessCollections = useMarketplaceContext(v => v.isSuccessCollections)
const handleQueryPlugins = useMarketplaceContext(v => v.handleQueryPlugins)
const page = useMarketplaceContext(v => v.page)
const handleMoreClick = useMarketplaceContext(v => v.handleMoreClick)
useEffect(() => {
if (!marketplaceCollectionsFromClient?.length && isSuccessCollections)
handleQueryPlugins()
}, [handleQueryPlugins, marketplaceCollections, marketplaceCollectionsFromClient, isSuccessCollections])
return (
<div
style={{ scrollbarGutter: 'stable' }}
className='relative flex grow flex-col bg-background-default-subtle px-12 py-2'>
{
plugins && (
<div className='mb-4 flex items-center pt-3'>
<div className='title-xl-semi-bold text-text-primary'>{t('plugin.marketplace.pluginsResult', { num: pluginsTotal })}</div>
<div className='mx-3 h-3.5 w-[1px] bg-divider-regular'></div>
<SortDropdown locale={locale} />
</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={marketplaceCollectionsFromClient || marketplaceCollections}
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMapFromClient || marketplaceCollectionPluginsMap}
plugins={plugins}
showInstallButton={showInstallButton}
locale={locale}
onMoreClick={handleMoreClick}
/>
)
}
</div>
)
}
export default ListWrapper

View File

@@ -0,0 +1,132 @@
'use client'
import { Trigger as TriggerIcon } from '@/app/components/base/icons/src/vender/plugin'
import cn from '@/utils/classnames'
import {
RiArchive2Line,
RiBrain2Line,
RiDatabase2Line,
RiHammerLine,
RiPuzzle2Line,
RiSpeakAiLine,
} from '@remixicon/react'
import { useCallback, useEffect } from 'react'
import { PluginCategoryEnum } from '../types'
import { useMarketplaceContext } from './context'
import {
useMixedTranslation,
useSearchBoxAutoAnimate,
} from './hooks'
export const PLUGIN_TYPE_SEARCH_MAP = {
all: 'all',
model: PluginCategoryEnum.model,
tool: PluginCategoryEnum.tool,
agent: PluginCategoryEnum.agent,
extension: PluginCategoryEnum.extension,
datasource: PluginCategoryEnum.datasource,
trigger: PluginCategoryEnum.trigger,
bundle: 'bundle',
}
type PluginTypeSwitchProps = {
locale?: string
className?: string
searchBoxAutoAnimate?: boolean
showSearchParams?: boolean
}
const PluginTypeSwitch = ({
locale,
className,
searchBoxAutoAnimate,
showSearchParams,
}: PluginTypeSwitchProps) => {
const { t } = useMixedTranslation(locale)
const activePluginType = useMarketplaceContext(s => s.activePluginType)
const handleActivePluginTypeChange = useMarketplaceContext(s => s.handleActivePluginTypeChange)
const { searchBoxCanAnimate } = useSearchBoxAutoAnimate(searchBoxAutoAnimate)
const options = [
{
value: PLUGIN_TYPE_SEARCH_MAP.all,
text: t('plugin.category.all'),
icon: null,
},
{
value: PLUGIN_TYPE_SEARCH_MAP.model,
text: t('plugin.category.models'),
icon: <RiBrain2Line className='mr-1.5 h-4 w-4' />,
},
{
value: PLUGIN_TYPE_SEARCH_MAP.tool,
text: t('plugin.category.tools'),
icon: <RiHammerLine className='mr-1.5 h-4 w-4' />,
},
{
value: PLUGIN_TYPE_SEARCH_MAP.datasource,
text: t('plugin.category.datasources'),
icon: <RiDatabase2Line className='mr-1.5 h-4 w-4' />,
},
{
value: PLUGIN_TYPE_SEARCH_MAP.trigger,
text: t('plugin.category.triggers'),
icon: <TriggerIcon className='mr-1.5 h-4 w-4' />,
},
{
value: PLUGIN_TYPE_SEARCH_MAP.agent,
text: t('plugin.category.agents'),
icon: <RiSpeakAiLine className='mr-1.5 h-4 w-4' />,
},
{
value: PLUGIN_TYPE_SEARCH_MAP.extension,
text: t('plugin.category.extensions'),
icon: <RiPuzzle2Line className='mr-1.5 h-4 w-4' />,
},
{
value: PLUGIN_TYPE_SEARCH_MAP.bundle,
text: t('plugin.category.bundles'),
icon: <RiArchive2Line className='mr-1.5 h-4 w-4' />,
},
]
const handlePopState = useCallback(() => {
if (!showSearchParams)
return
const url = new URL(window.location.href)
const category = url.searchParams.get('category') || PLUGIN_TYPE_SEARCH_MAP.all
handleActivePluginTypeChange(category)
}, [showSearchParams, handleActivePluginTypeChange])
useEffect(() => {
window.addEventListener('popstate', handlePopState)
return () => {
window.removeEventListener('popstate', handlePopState)
}
}, [handlePopState])
return (
<div className={cn(
'flex shrink-0 items-center justify-center space-x-2 bg-background-body py-3',
searchBoxCanAnimate && 'sticky top-[56px] z-10',
className,
)}>
{
options.map(option => (
<div
key={option.value}
className={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',
activePluginType === option.value && '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',
)}
onClick={() => {
handleActivePluginTypeChange(option.value)
}}
>
{option.icon}
{option.text}
</div>
))
}
</div>
)
}
export default PluginTypeSwitch

View File

@@ -0,0 +1,136 @@
'use client'
import { RiCloseLine, RiSearchLine } from '@remixicon/react'
import TagsFilter from './tags-filter'
import ActionButton from '@/app/components/base/action-button'
import cn from '@/utils/classnames'
import { RiAddLine } from '@remixicon/react'
import Divider from '@/app/components/base/divider'
type SearchBoxProps = {
search: string
onSearchChange: (search: string) => void
wrapperClassName?: string
inputClassName?: string
tags: string[]
onTagsChange: (tags: string[]) => void
placeholder?: string
locale?: string
supportAddCustomTool?: boolean
usedInMarketplace?: boolean
onShowAddCustomCollectionModal?: () => void
onAddedCustomTool?: () => void
autoFocus?: boolean
}
const SearchBox = ({
search,
onSearchChange,
wrapperClassName,
inputClassName,
tags,
onTagsChange,
placeholder = '',
locale,
usedInMarketplace = false,
supportAddCustomTool,
onShowAddCustomCollectionModal,
autoFocus = false,
}: SearchBoxProps) => {
return (
<div
className={cn('z-[11] flex items-center', wrapperClassName)}
>
<div className={
cn('flex items-center',
usedInMarketplace && 'rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur p-1.5 shadow-md',
!usedInMarketplace && 'radius-md border border-transparent bg-components-input-bg-normal focus-within:border-components-input-border-active hover:border-components-input-border-hover',
inputClassName,
)
}>
{
usedInMarketplace && (
<>
<TagsFilter
tags={tags}
onTagsChange={onTagsChange}
usedInMarketplace
locale={locale}
/>
<Divider type='vertical' className='mx-1 h-3.5' />
<div className='flex grow items-center gap-x-2 p-1'>
<input
className={cn(
'body-md-medium inline-block grow appearance-none bg-transparent text-text-secondary outline-none',
)}
value={search}
onChange={(e) => {
onSearchChange(e.target.value)
}}
placeholder={placeholder}
/>
{
search && (
<ActionButton
onClick={() => onSearchChange('')}
className='shrink-0'
>
<RiCloseLine className='size-4' />
</ActionButton>
)
}
</div>
</>
)
}
{
!usedInMarketplace && (
<>
<div className='flex grow items-center py-[7px] pl-2 pr-3'>
<RiSearchLine className='size-4 text-components-input-text-placeholder' />
<input
autoFocus={autoFocus}
className={cn(
'system-sm-regular ml-1.5 mr-1 inline-block grow appearance-none bg-transparent text-components-input-text-filled outline-none placeholder:text-components-input-text-placeholder',
search && 'mr-2',
)}
value={search}
onChange={(e) => {
onSearchChange(e.target.value)
}}
placeholder={placeholder}
/>
{
search && (
<ActionButton
onClick={() => onSearchChange('')}
className='shrink-0'
>
<RiCloseLine className='size-4' />
</ActionButton>
)
}
</div>
<Divider type='vertical' className='mx-0 mr-0.5 h-3.5' />
<TagsFilter
tags={tags}
onTagsChange={onTagsChange}
locale={locale}
/>
</>
)
}
</div>
{supportAddCustomTool && (
<div className='flex shrink-0 items-center'>
<ActionButton
className='ml-2 rounded-full bg-components-button-primary-bg text-components-button-primary-text hover:bg-components-button-primary-bg hover:text-components-button-primary-text'
onClick={onShowAddCustomCollectionModal}
>
<RiAddLine className='h-4 w-4' />
</ActionButton>
</div>
)}
</div>
)
}
export default SearchBox

View File

@@ -0,0 +1,46 @@
'use client'
import { useMarketplaceContext } from '../context'
import {
useMixedTranslation,
useSearchBoxAutoAnimate,
} from '../hooks'
import SearchBox from './index'
import cn from '@/utils/classnames'
type SearchBoxWrapperProps = {
locale?: string
searchBoxAutoAnimate?: boolean
}
const SearchBoxWrapper = ({
locale,
searchBoxAutoAnimate,
}: SearchBoxWrapperProps) => {
const { t } = useMixedTranslation(locale)
const intersected = useMarketplaceContext(v => v.intersected)
const searchPluginText = useMarketplaceContext(v => v.searchPluginText)
const handleSearchPluginTextChange = useMarketplaceContext(v => v.handleSearchPluginTextChange)
const filterPluginTags = useMarketplaceContext(v => v.filterPluginTags)
const handleFilterPluginTagsChange = useMarketplaceContext(v => v.handleFilterPluginTagsChange)
const { searchBoxCanAnimate } = useSearchBoxAutoAnimate(searchBoxAutoAnimate)
return (
<SearchBox
wrapperClassName={cn(
'z-[0] mx-auto w-[640px] shrink-0',
searchBoxCanAnimate && 'sticky top-3 z-[11]',
!intersected && searchBoxCanAnimate && 'w-[508px] transition-[width] duration-300',
)}
inputClassName='w-full'
search={searchPluginText}
onSearchChange={handleSearchPluginTextChange}
tags={filterPluginTags}
onTagsChange={handleFilterPluginTagsChange}
locale={locale}
placeholder={t('plugin.searchPlugins')}
usedInMarketplace
/>
)
}
export default SearchBoxWrapper

View File

@@ -0,0 +1,114 @@
'use client'
import { useState } from 'react'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import Checkbox from '@/app/components/base/checkbox'
import Input from '@/app/components/base/input'
import { useTags } from '@/app/components/plugins/hooks'
import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks'
import MarketplaceTrigger from './trigger/marketplace'
import ToolSelectorTrigger from './trigger/tool-selector'
type TagsFilterProps = {
tags: string[]
onTagsChange: (tags: string[]) => void
usedInMarketplace?: boolean
locale?: string
}
const TagsFilter = ({
tags,
onTagsChange,
usedInMarketplace = false,
locale,
}: TagsFilterProps) => {
const { t } = useMixedTranslation(locale)
const [open, setOpen] = useState(false)
const [searchText, setSearchText] = useState('')
const { tags: options, tagsMap } = useTags(t)
const filteredOptions = options.filter(option => option.label.toLowerCase().includes(searchText.toLowerCase()))
const handleCheck = (id: string) => {
if (tags.includes(id))
onTagsChange(tags.filter((tag: string) => tag !== id))
else
onTagsChange([...tags, id])
}
const selectedTagsLength = tags.length
return (
<PortalToFollowElem
placement='bottom-start'
offset={{
mainAxis: 4,
crossAxis: -6,
}}
open={open}
onOpenChange={setOpen}
>
<PortalToFollowElemTrigger
className='shrink-0'
onClick={() => setOpen(v => !v)}
>
{
usedInMarketplace && (
<MarketplaceTrigger
selectedTagsLength={selectedTagsLength}
open={open}
tags={tags}
tagsMap={tagsMap}
locale={locale}
onTagsChange={onTagsChange}
/>
)
}
{
!usedInMarketplace && (
<ToolSelectorTrigger
selectedTagsLength={selectedTagsLength}
open={open}
tags={tags}
tagsMap={tagsMap}
onTagsChange={onTagsChange}
/>
)
}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[1000]'>
<div className='w-[240px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm'>
<div className='p-2 pb-1'>
<Input
showLeftIcon
value={searchText}
onChange={e => setSearchText(e.target.value)}
placeholder={t('pluginTags.searchTags') || ''}
/>
</div>
<div className='max-h-[448px] overflow-y-auto p-1'>
{
filteredOptions.map(option => (
<div
key={option.name}
className='flex h-7 cursor-pointer select-none items-center rounded-lg px-2 py-1.5 hover:bg-state-base-hover'
onClick={() => handleCheck(option.name)}
>
<Checkbox
className='mr-1'
checked={tags.includes(option.name)}
/>
<div className='system-sm-medium px-1 text-text-secondary'>
{option.label}
</div>
</div>
))
}
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default TagsFilter

View File

@@ -0,0 +1,75 @@
import React from 'react'
import { RiArrowDownSLine, RiCloseCircleFill, RiFilter3Line } from '@remixicon/react'
import type { Tag } from '../../../hooks'
import cn from '@/utils/classnames'
import { useMixedTranslation } from '../../hooks'
type MarketplaceTriggerProps = {
selectedTagsLength: number
open: boolean
tags: string[]
tagsMap: Record<string, Tag>
locale?: string
onTagsChange: (tags: string[]) => void
}
const MarketplaceTrigger = ({
selectedTagsLength,
open,
tags,
tagsMap,
locale,
onTagsChange,
}: MarketplaceTriggerProps) => {
const { t } = useMixedTranslation(locale)
return (
<div
className={cn(
'flex h-8 cursor-pointer select-none items-center rounded-lg px-2 py-1 text-text-tertiary',
!!selectedTagsLength && 'border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs shadow-shadow-shadow-3',
open && !selectedTagsLength && 'bg-state-base-hover',
)}
>
<div className='p-0.5'>
<RiFilter3Line className={cn('size-4', !!selectedTagsLength && 'text-text-secondary')} />
</div>
<div className='system-sm-medium flex items-center gap-x-1 p-1'>
{
!selectedTagsLength && <span>{t('pluginTags.allTags')}</span>
}
{
!!selectedTagsLength && (
<span className='text-text-secondary'>
{tags.map(tag => tagsMap[tag].label).slice(0, 2).join(',')}
</span>
)
}
{
selectedTagsLength > 2 && (
<div className='system-xs-medium text-text-tertiary'>
+{selectedTagsLength - 2}
</div>
)
}
</div>
{
!!selectedTagsLength && (
<RiCloseCircleFill
className='size-4 text-text-quaternary'
onClick={() => onTagsChange([])}
/>
)
}
{
!selectedTagsLength && (
<div className='p-0.5'>
<RiArrowDownSLine className='size-4 text-text-tertiary' />
</div>
)
}
</div>
)
}
export default React.memo(MarketplaceTrigger)

View File

@@ -0,0 +1,63 @@
import React from 'react'
import type { Tag } from '../../../hooks'
import cn from '@/utils/classnames'
import { RiCloseCircleFill, RiPriceTag3Line } from '@remixicon/react'
type ToolSelectorTriggerProps = {
selectedTagsLength: number
open: boolean
tags: string[]
tagsMap: Record<string, Tag>
onTagsChange: (tags: string[]) => void
}
const ToolSelectorTrigger = ({
selectedTagsLength,
open,
tags,
tagsMap,
onTagsChange,
}: ToolSelectorTriggerProps) => {
return (
<div className={cn(
'flex h-7 cursor-pointer select-none items-center rounded-md p-0.5 text-text-tertiary',
!selectedTagsLength && 'py-1 pl-1.5 pr-2',
!!selectedTagsLength && 'border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg py-0.5 pl-1 pr-1.5 shadow-xs shadow-shadow-shadow-3',
open && !selectedTagsLength && 'bg-state-base-hover',
)}
>
<div className='p-0.5'>
<RiPriceTag3Line className={cn('size-4', !!selectedTagsLength && 'text-text-secondary')} />
</div>
{
!!selectedTagsLength && (
<div className='system-sm-medium flex items-center gap-x-0.5 px-0.5 py-1'>
<span className='text-text-secondary'>
{tags.map(tag => tagsMap[tag].label).slice(0, 2).join(',')}
</span>
{
selectedTagsLength > 2 && (
<div className='system-xs-medium text-text-tertiary'>
+{selectedTagsLength - 2}
</div>
)
}
</div>
)
}
{
!!selectedTagsLength && (
<RiCloseCircleFill
className='size-4 text-text-quaternary'
onClick={(e) => {
e.stopPropagation()
onTagsChange([])
}}
/>
)
}
</div>
)
}
export default React.memo(ToolSelectorTrigger)

View File

@@ -0,0 +1,94 @@
'use client'
import { useState } from 'react'
import {
RiArrowDownSLine,
RiCheckLine,
} from '@remixicon/react'
import { useMarketplaceContext } from '../context'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { useMixedTranslation } from '@/app/components/plugins/marketplace/hooks'
type SortDropdownProps = {
locale?: string
}
const SortDropdown = ({
locale,
}: SortDropdownProps) => {
const { t } = useMixedTranslation(locale)
const options = [
{
value: 'install_count',
order: 'DESC',
text: t('plugin.marketplace.sortOption.mostPopular'),
},
{
value: 'version_updated_at',
order: 'DESC',
text: t('plugin.marketplace.sortOption.recentlyUpdated'),
},
{
value: 'created_at',
order: 'DESC',
text: t('plugin.marketplace.sortOption.newlyReleased'),
},
{
value: 'created_at',
order: 'ASC',
text: t('plugin.marketplace.sortOption.firstReleased'),
},
]
const sort = useMarketplaceContext(v => v.sort)
const handleSortChange = useMarketplaceContext(v => v.handleSortChange)
const [open, setOpen] = useState(false)
const selectedOption = options.find(option => option.value === sort.sortBy && option.order === sort.sortOrder)!
return (
<PortalToFollowElem
placement='bottom-start'
offset={{
mainAxis: 4,
crossAxis: 0,
}}
open={open}
onOpenChange={setOpen}
>
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
<div className='flex h-8 cursor-pointer items-center rounded-lg bg-state-base-hover-alt px-2 pr-3'>
<span className='system-sm-regular mr-1 text-text-secondary'>
{t('plugin.marketplace.sortBy')}
</span>
<span className='system-sm-medium mr-1 text-text-primary'>
{selectedOption.text}
</span>
<RiArrowDownSLine className='h-4 w-4 text-text-tertiary' />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent>
<div className='rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-sm'>
{
options.map(option => (
<div
key={`${option.value}-${option.order}`}
className='system-md-regular flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 pr-2 text-text-primary hover:bg-components-panel-on-panel-item-bg-hover'
onClick={() => handleSortChange({ sortBy: option.value, sortOrder: option.order })}
>
{option.text}
{
sort.sortBy === option.value && sort.sortOrder === option.order && (
<RiCheckLine className='ml-2 h-4 w-4 text-text-accent' />
)
}
</div>
))
}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default SortDropdown

View File

@@ -0,0 +1,59 @@
import type { Plugin } from '../types'
export type SearchParamsFromCollection = {
query?: string
sort_by?: string
sort_order?: string
}
export type MarketplaceCollection = {
name: string
label: Record<string, string>
description: Record<string, string>
rule: string
created_at: string
updated_at: string
searchable?: boolean
search_params?: SearchParamsFromCollection
}
export type MarketplaceCollectionsResponse = {
collections: MarketplaceCollection[]
total: number
}
export type MarketplaceCollectionPluginsResponse = {
plugins: Plugin[]
total: number
}
export type PluginsSearchParams = {
query: string
page?: number
pageSize?: number
sortBy?: string
sortOrder?: string
category?: string
tags?: string[]
exclude?: string[]
type?: 'plugin' | 'bundle'
}
export type PluginsSort = {
sortBy: string
sortOrder: string
}
export type CollectionsAndPluginsSearchParams = {
category?: string
condition?: string
exclude?: string[]
type?: 'plugin' | 'bundle'
}
export type SearchParams = {
language?: string
q?: string
tags?: string
category?: string
}

View File

@@ -0,0 +1,156 @@
import { PLUGIN_TYPE_SEARCH_MAP } from './plugin-type-switch'
import type { Plugin } from '@/app/components/plugins/types'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import type {
CollectionsAndPluginsSearchParams,
MarketplaceCollection,
PluginsSearchParams,
} from '@/app/components/plugins/marketplace/types'
import {
APP_VERSION,
IS_MARKETPLACE,
MARKETPLACE_API_PREFIX,
} from '@/config'
import { getMarketplaceUrl } from '@/utils/var'
export const getPluginIconInMarketplace = (plugin: Plugin) => {
if (plugin.type === 'bundle')
return `${MARKETPLACE_API_PREFIX}/bundles/${plugin.org}/${plugin.name}/icon`
return `${MARKETPLACE_API_PREFIX}/plugins/${plugin.org}/${plugin.name}/icon`
}
export const getFormattedPlugin = (bundle: any) => {
if (bundle.type === 'bundle') {
return {
...bundle,
icon: getPluginIconInMarketplace(bundle),
brief: bundle.description,
label: bundle.labels,
}
}
return {
...bundle,
icon: getPluginIconInMarketplace(bundle),
}
}
export const getPluginLinkInMarketplace = (plugin: Plugin, params?: Record<string, string | undefined>) => {
if (plugin.type === 'bundle')
return getMarketplaceUrl(`/bundles/${plugin.org}/${plugin.name}`, params)
return getMarketplaceUrl(`/plugins/${plugin.org}/${plugin.name}`, params)
}
export const getPluginDetailLinkInMarketplace = (plugin: Plugin) => {
if (plugin.type === 'bundle')
return `/bundles/${plugin.org}/${plugin.name}`
return `/plugins/${plugin.org}/${plugin.name}`
}
export const getMarketplacePluginsByCollectionId = async (collectionId: string, query?: CollectionsAndPluginsSearchParams) => {
let plugins: Plugin[]
try {
const url = `${MARKETPLACE_API_PREFIX}/collections/${collectionId}/plugins`
const headers = new Headers({
'X-Dify-Version': !IS_MARKETPLACE ? APP_VERSION : '999.0.0',
})
const marketplaceCollectionPluginsData = await globalThis.fetch(
url,
{
cache: 'no-store',
method: 'POST',
headers,
body: JSON.stringify({
category: query?.category,
exclude: query?.exclude,
type: query?.type,
}),
},
)
const marketplaceCollectionPluginsDataJson = await marketplaceCollectionPluginsData.json()
plugins = marketplaceCollectionPluginsDataJson.data.plugins.map((plugin: Plugin) => {
return getFormattedPlugin(plugin)
})
}
// eslint-disable-next-line unused-imports/no-unused-vars
catch (e) {
plugins = []
}
return plugins
}
export const getMarketplaceCollectionsAndPlugins = async (query?: CollectionsAndPluginsSearchParams) => {
let marketplaceCollections = [] as MarketplaceCollection[]
let marketplaceCollectionPluginsMap = {} as Record<string, Plugin[]>
try {
let marketplaceUrl = `${MARKETPLACE_API_PREFIX}/collections?page=1&page_size=100`
if (query?.condition)
marketplaceUrl += `&condition=${query.condition}`
if (query?.type)
marketplaceUrl += `&type=${query.type}`
const headers = new Headers({
'X-Dify-Version': !IS_MARKETPLACE ? APP_VERSION : '999.0.0',
})
const marketplaceCollectionsData = await globalThis.fetch(marketplaceUrl, { headers, cache: 'no-store' })
const marketplaceCollectionsDataJson = await marketplaceCollectionsData.json()
marketplaceCollections = marketplaceCollectionsDataJson.data.collections
await Promise.all(marketplaceCollections.map(async (collection: MarketplaceCollection) => {
const plugins = await getMarketplacePluginsByCollectionId(collection.name, query)
marketplaceCollectionPluginsMap[collection.name] = plugins
}))
}
// eslint-disable-next-line unused-imports/no-unused-vars
catch (e) {
marketplaceCollections = []
marketplaceCollectionPluginsMap = {}
}
return {
marketplaceCollections,
marketplaceCollectionPluginsMap,
}
}
export const getMarketplaceListCondition = (pluginType: string) => {
if ([PluginCategoryEnum.tool, PluginCategoryEnum.agent, PluginCategoryEnum.model, PluginCategoryEnum.datasource, PluginCategoryEnum.trigger].includes(pluginType as PluginCategoryEnum))
return `category=${pluginType}`
if (pluginType === PluginCategoryEnum.extension)
return 'category=endpoint'
if (pluginType === 'bundle')
return 'type=bundle'
return ''
}
export const getMarketplaceListFilterType = (category: string) => {
if (category === PLUGIN_TYPE_SEARCH_MAP.all)
return undefined
if (category === PLUGIN_TYPE_SEARCH_MAP.bundle)
return 'bundle'
return 'plugin'
}
export const updateSearchParams = (pluginsSearchParams: PluginsSearchParams) => {
const { query, category, tags } = pluginsSearchParams
const url = new URL(window.location.href)
const categoryChanged = url.searchParams.get('category') !== category
if (query)
url.searchParams.set('q', query)
else
url.searchParams.delete('q')
if (category)
url.searchParams.set('category', category)
else
url.searchParams.delete('category')
if (tags && tags.length)
url.searchParams.set('tags', tags.join(','))
else
url.searchParams.delete('tags')
history[`${categoryChanged ? 'pushState' : 'replaceState'}`]({}, '', url)
}