feat: marketplace list

This commit is contained in:
StyleZhang 2024-10-29 10:51:41 +08:00
parent ca9e23d6ea
commit 9a65c3391b
10 changed files with 204 additions and 290 deletions

View File

@ -2,21 +2,42 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { import {
useCallback,
useState, useState,
} from 'react' } from 'react'
import { import {
createContext, createContext,
useContextSelector, useContextSelector,
} from 'use-context-selector' } from 'use-context-selector'
import { useDebounceFn } from 'ahooks'
import { PLUGIN_TYPE_SEARCH_MAP } from './plugin-type-switch'
import type { Plugin } from '../types'
import type { PluginsSearchParams } from './types'
export type MarketplaceContextValue = { export type MarketplaceContextValue = {
intersected: boolean intersected: boolean
setIntersected: (intersected: boolean) => void setIntersected: (intersected: boolean) => void
searchPluginText: string
handleSearchPluginTextChange: (text: string) => void
filterPluginTags: string[]
handleFilterPluginTagsChange: (tags: string[]) => void
activePluginType: string
handleActivePluginTypeChange: (type: string) => void
plugins?: Plugin[]
setPlugins?: (plugins: Plugin[]) => void
} }
export const MarketplaceContext = createContext<MarketplaceContextValue>({ export const MarketplaceContext = createContext<MarketplaceContextValue>({
intersected: true, intersected: true,
setIntersected: () => {}, setIntersected: () => {},
searchPluginText: '',
handleSearchPluginTextChange: () => {},
filterPluginTags: [],
handleFilterPluginTagsChange: () => {},
activePluginType: PLUGIN_TYPE_SEARCH_MAP.all,
handleActivePluginTypeChange: () => {},
plugins: undefined,
setPlugins: () => {},
}) })
type MarketplaceContextProviderProps = { type MarketplaceContextProviderProps = {
@ -31,12 +52,69 @@ export const MarketplaceContextProvider = ({
children, children,
}: MarketplaceContextProviderProps) => { }: MarketplaceContextProviderProps) => {
const [intersected, setIntersected] = useState(true) const [intersected, setIntersected] = useState(true)
const [searchPluginText, setSearchPluginText] = useState('')
const [filterPluginTags, setFilterPluginTags] = useState<string[]>([])
const [activePluginType, setActivePluginType] = useState(PLUGIN_TYPE_SEARCH_MAP.all)
const [plugins, setPlugins] = useState<Plugin[]>()
const handleUpdatePlugins = useCallback((query: PluginsSearchParams) => {
const fetchPlugins = async () => {
const response = await fetch(
'https://marketplace.dify.dev/api/v1/plugins/search/basic',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: query.query,
page: 1,
page_size: 10,
sort_by: query.sortBy,
sort_order: query.sortOrder,
category: query.category,
tag: query.tag,
}),
},
)
const data = await response.json()
setPlugins(data.data.plugins)
}
fetchPlugins()
}, [])
const { run: handleUpdatePluginsWithDebounced } = useDebounceFn(handleUpdatePlugins, {
wait: 500,
})
const handleSearchPluginTextChange = useCallback((text: string) => {
setSearchPluginText(text)
handleUpdatePluginsWithDebounced({ query: text })
}, [handleUpdatePluginsWithDebounced])
const handleFilterPluginTagsChange = useCallback((tags: string[]) => {
setFilterPluginTags(tags)
}, [])
const handleActivePluginTypeChange = useCallback((type: string) => {
setActivePluginType(type)
}, [])
return ( return (
<MarketplaceContext.Provider <MarketplaceContext.Provider
value={{ value={{
intersected, intersected,
setIntersected, setIntersected,
searchPluginText,
handleSearchPluginTextChange,
filterPluginTags,
handleFilterPluginTagsChange,
activePluginType,
handleActivePluginTypeChange,
plugins,
setPlugins,
}} }}
> >
{children} {children}

View File

@ -4,7 +4,7 @@ import type { Plugin } from '@/app/components/plugins/types'
import Card from '@/app/components/plugins/card' import Card from '@/app/components/plugins/card'
import CardMoreInfo from '@/app/components/plugins/card/card-more-info' import CardMoreInfo from '@/app/components/plugins/card/card-more-info'
interface ListWithCollectionProps { type ListWithCollectionProps = {
marketplaceCollections: MarketplaceCollection[] marketplaceCollections: MarketplaceCollection[]
marketplaceCollectionPluginsMap: Record<string, Plugin[]> marketplaceCollectionPluginsMap: Record<string, Plugin[]>
} }

View File

@ -1,9 +1,10 @@
'use client' 'use client'
import type { Plugin } from '../../types' import type { Plugin } from '../../types'
import type { MarketplaceCollection } from '../types' import type { MarketplaceCollection } from '../types'
import { useMarketplaceContext } from '../context'
import List from './index' import List from './index'
interface ListWrapperProps { type ListWrapperProps = {
marketplaceCollections: MarketplaceCollection[] marketplaceCollections: MarketplaceCollection[]
marketplaceCollectionPluginsMap: Record<string, Plugin[]> marketplaceCollectionPluginsMap: Record<string, Plugin[]>
} }
@ -11,10 +12,13 @@ const ListWrapper = ({
marketplaceCollections, marketplaceCollections,
marketplaceCollectionPluginsMap, marketplaceCollectionPluginsMap,
}: ListWrapperProps) => { }: ListWrapperProps) => {
const plugins = useMarketplaceContext(s => s.plugins)
return ( return (
<List <List
marketplaceCollections={marketplaceCollections} marketplaceCollections={marketplaceCollections}
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap} marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap}
plugins={plugins}
/> />
) )
} }

View File

@ -1,25 +1,22 @@
'use client' 'use client'
import { useState } from 'react'
import { PluginType } from '../types'
import { import {
RiArchive2Line, RiArchive2Line,
RiBrain2Line, RiBrain2Line,
RiHammerLine, RiHammerLine,
RiPuzzle2Line, RiPuzzle2Line,
} from '@remixicon/react' } from '@remixicon/react'
import { PluginType } from '../types'
import { useMarketplaceContext } from './context'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
const PLUGIN_TYPE_SEARCH_MAP = { export const PLUGIN_TYPE_SEARCH_MAP = {
all: 'all', all: 'all',
model: PluginType.model, model: PluginType.model,
tool: PluginType.tool, tool: PluginType.tool,
extension: PluginType.extension, extension: PluginType.extension,
bundle: 'bundle', bundle: 'bundle',
} }
type PluginTypeSwitchProps = {
onChange?: (type: string) => void
}
const options = [ const options = [
{ {
value: PLUGIN_TYPE_SEARCH_MAP.all, value: PLUGIN_TYPE_SEARCH_MAP.all,
@ -47,10 +44,9 @@ const options = [
icon: <RiArchive2Line className='mr-1.5 w-4 h-4' />, icon: <RiArchive2Line className='mr-1.5 w-4 h-4' />,
}, },
] ]
const PluginTypeSwitch = ({ const PluginTypeSwitch = () => {
onChange, const activePluginType = useMarketplaceContext(s => s.activePluginType)
}: PluginTypeSwitchProps) => { const handleActivePluginTypeChange = useMarketplaceContext(s => s.handleActivePluginTypeChange)
const [activeType, setActiveType] = useState(PLUGIN_TYPE_SEARCH_MAP.all)
return ( return (
<div className={cn( <div className={cn(
@ -62,11 +58,10 @@ const PluginTypeSwitch = ({
key={option.value} key={option.value}
className={cn( className={cn(
'flex items-center px-3 h-8 border border-transparent rounded-xl cursor-pointer hover:bg-state-base-hover hover:text-text-secondary system-md-medium text-text-tertiary', 'flex items-center px-3 h-8 border border-transparent rounded-xl cursor-pointer hover:bg-state-base-hover hover:text-text-secondary system-md-medium text-text-tertiary',
activeType === 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', 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={() => { onClick={() => {
setActiveType(option.value) handleActivePluginTypeChange(option.value)
onChange?.(option.value)
}} }}
> >
{option.icon} {option.icon}

View File

@ -1,29 +1,14 @@
'use client' 'use client'
import {
useCallback,
useState,
} from 'react'
import { RiCloseLine } from '@remixicon/react' import { RiCloseLine } from '@remixicon/react'
import { useMarketplaceContext } from '../context' import { useMarketplaceContext } from '../context'
import TagsFilter from './tags-filter' import TagsFilter from './tags-filter'
import ActionButton from '@/app/components/base/action-button' import ActionButton from '@/app/components/base/action-button'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
type SearchBoxProps = { const SearchBox = () => {
onChange?: (searchText: string, tags: string[]) => void
}
const SearchBox = ({
onChange,
}: SearchBoxProps) => {
const intersected = useMarketplaceContext(v => v.intersected) const intersected = useMarketplaceContext(v => v.intersected)
const [searchText, setSearchText] = useState('') const searchPluginText = useMarketplaceContext(v => v.searchPluginText)
const [selectedTags, setSelectedTags] = useState<string[]>([]) const handleSearchPluginTextChange = useMarketplaceContext(v => v.handleSearchPluginTextChange)
const handleTagsChange = useCallback((tags: string[]) => {
setSelectedTags(tags)
onChange?.(searchText, tags)
}, [searchText, onChange])
return ( return (
<div <div
@ -32,24 +17,20 @@ const SearchBox = ({
!intersected && 'w-[508px] transition-[width] duration-300', !intersected && 'w-[508px] transition-[width] duration-300',
)} )}
> >
<TagsFilter <TagsFilter />
value={selectedTags}
onChange={handleTagsChange}
/>
<div className='mx-1 w-[1px] h-3.5 bg-divider-regular'></div> <div className='mx-1 w-[1px] h-3.5 bg-divider-regular'></div>
<div className='grow flex items-center p-1 pl-2'> <div className='grow flex items-center p-1 pl-2'>
<div className='flex items-center mr-2 py-0.5 w-full'> <div className='flex items-center mr-2 py-0.5 w-full'>
<input <input
className='grow block outline-none appearance-none body-md-medium text-text-secondary' className='grow block outline-none appearance-none body-md-medium text-text-secondary'
value={searchText} value={searchPluginText}
onChange={(e) => { onChange={(e) => {
setSearchText(e.target.value) handleSearchPluginTextChange(e.target.value)
onChange?.(e.target.value, selectedTags)
}} }}
/> />
{ {
searchText && ( searchPluginText && (
<ActionButton onClick={() => setSearchText('')}> <ActionButton onClick={() => handleSearchPluginTextChange('')}>
<RiCloseLine className='w-4 h-4' /> <RiCloseLine className='w-4 h-4' />
</ActionButton> </ActionButton>
) )

View File

@ -6,6 +6,7 @@ import {
RiCloseCircleFill, RiCloseCircleFill,
RiFilter3Line, RiFilter3Line,
} from '@remixicon/react' } from '@remixicon/react'
import { useMarketplaceContext } from '../context'
import { import {
PortalToFollowElem, PortalToFollowElem,
PortalToFollowElemContent, PortalToFollowElemContent,
@ -15,14 +16,9 @@ import Checkbox from '@/app/components/base/checkbox'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
import Input from '@/app/components/base/input' import Input from '@/app/components/base/input'
type TagsFilterProps = { const TagsFilter = () => {
value: string[] const filterPluginTags = useMarketplaceContext(v => v.filterPluginTags)
onChange: (tags: string[]) => void const handleFilterPluginTagsChange = useMarketplaceContext(v => v.handleFilterPluginTagsChange)
}
const TagsFilter = ({
value,
onChange,
}: TagsFilterProps) => {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [searchText, setSearchText] = useState('') const [searchText, setSearchText] = useState('')
const options = [ const options = [
@ -37,12 +33,12 @@ const TagsFilter = ({
] ]
const filteredOptions = options.filter(option => option.text.toLowerCase().includes(searchText.toLowerCase())) const filteredOptions = options.filter(option => option.text.toLowerCase().includes(searchText.toLowerCase()))
const handleCheck = (id: string) => { const handleCheck = (id: string) => {
if (value.includes(id)) if (filterPluginTags.includes(id))
onChange(value.filter(tag => tag !== id)) handleFilterPluginTagsChange(filterPluginTags.filter((tag: string) => tag !== id))
else else
onChange([...value, id]) handleFilterPluginTagsChange([...filterPluginTags, id])
} }
const selectedTagsLength = value.length const selectedTagsLength = filterPluginTags.length
return ( return (
<PortalToFollowElem <PortalToFollowElem
@ -70,7 +66,7 @@ const TagsFilter = ({
!selectedTagsLength && 'All Tags' !selectedTagsLength && 'All Tags'
} }
{ {
!!selectedTagsLength && value.slice(0, 2).join(',') !!selectedTagsLength && filterPluginTags.slice(0, 2).join(',')
} }
{ {
selectedTagsLength > 2 && ( selectedTagsLength > 2 && (
@ -84,7 +80,7 @@ const TagsFilter = ({
!!selectedTagsLength && ( !!selectedTagsLength && (
<RiCloseCircleFill <RiCloseCircleFill
className='w-4 h-4 text-text-quaternary cursor-pointer' className='w-4 h-4 text-text-quaternary cursor-pointer'
onClick={() => onChange([])} onClick={() => handleFilterPluginTagsChange([])}
/> />
) )
} }
@ -115,7 +111,7 @@ const TagsFilter = ({
> >
<Checkbox <Checkbox
className='mr-1' className='mr-1'
checked={value.includes(option.value)} checked={filterPluginTags.includes(option.value)}
/> />
<div className='px-1 system-sm-medium text-text-secondary'> <div className='px-1 system-sm-medium text-text-secondary'>
{option.text} {option.text}

View File

@ -17,3 +17,13 @@ export type MarketplaceCollectionPluginsResponse = {
plugins: Plugin[] plugins: Plugin[]
total: number total: number
} }
export type PluginsSearchParams = {
query: string
page?: number
pageSize?: number
sortBy?: string
sortOrder?: string
category?: string
tag?: string
}

View File

@ -1,233 +0,0 @@
import { RiArrowUpDoubleLine } from '@remixicon/react'
import Card from '@/app/components/plugins/card'
import CardMoreInfo from '@/app/components/plugins/card/card-more-info'
import { toolNotion } from '@/app/components/plugins/card/card-mock'
import { useGetLanguage } from '@/context/i18n'
type MarketplaceProps = {
onMarketplaceScroll: () => void
}
const Marketplace = ({
onMarketplaceScroll,
}: MarketplaceProps) => {
const locale = useGetLanguage()
return (
<div className='shrink-0 sticky -bottom-[442px] h-[530px] overflow-y-auto px-12 py-2 pt-0 bg-background-default-subtle'>
<RiArrowUpDoubleLine
className='absolute top-2 left-1/2 -translate-x-1/2 w-4 h-4 text-text-quaternary cursor-pointer'
onClick={() => onMarketplaceScroll()}
/>
<div className='sticky top-0 pt-5 pb-3 bg-background-default-subtle z-10'>
<div className='title-2xl-semi-bold bg-gradient-to-r from-[rgba(11,165,236,0.95)] to-[rgba(21,90,239,0.95)] bg-clip-text text-transparent'>More from Marketplace</div>
<div className='flex items-center text-center body-md-regular text-text-tertiary'>
Discover
<span className="relative ml-1 body-md-medium text-text-secondary after:content-[''] after:absolute after:left-0 after:bottom-[1.5px] after:w-full after:h-2 after:bg-text-text-selected">
models
</span>
,
<span className="relative ml-1 body-md-medium text-text-secondary after:content-[''] after:absolute after:left-0 after:bottom-[1.5px] after:w-full after:h-2 after:bg-text-text-selected">
tools
</span>
,
<span className="relative ml-1 mr-1 body-md-medium text-text-secondary after:content-[''] after:absolute after:left-0 after:bottom-[1.5px] after:w-full after:h-2 after:bg-text-text-selected">
extensions
</span>
and
<span className="relative ml-1 mr-1 body-md-medium text-text-secondary after:content-[''] after:absolute after:left-0 after:bottom-[1.5px] after:w-full after:h-2 after:bg-text-text-selected">
bundles
</span>
in Dify Marketplace
</div>
</div>
<div className='py-3'>
<div className='title-xl-semi-bold text-text-primary'>Featured</div>
<div className='system-xs-regular text-text-tertiary'>Our top picks to get you started</div>
<div className='grid grid-cols-4 gap-3 mt-2'>
<Card
payload={toolNotion as any}
footer={
<CardMoreInfo downloadCount={1234} tags={['Search', 'Productivity']} />
}
/>
<Card
payload={toolNotion as any}
footer={
<CardMoreInfo downloadCount={1234} tags={['Search', 'Productivity']} />
}
/>
<Card
payload={toolNotion as any}
footer={
<CardMoreInfo downloadCount={1234} tags={['Search', 'Productivity']} />
}
/>
<Card
payload={toolNotion as any}
footer={
<CardMoreInfo downloadCount={1234} tags={['Search', 'Productivity']} />
}
/>
<Card
payload={toolNotion as any}
footer={
<CardMoreInfo downloadCount={1234} tags={['Search', 'Productivity']} />
}
/>
</div>
</div>
<div className='py-3'>
<div className='title-xl-semi-bold text-text-primary'>Popular</div>
<div className='system-xs-regular text-text-tertiary'>Explore the library and discover the incredible work of our community</div>
<div className='grid grid-cols-4 gap-3 mt-2'>
<Card
payload={toolNotion as any}
footer={
<CardMoreInfo downloadCount={1234} tags={['Search', 'Productivity']} />
}
/>
<Card
payload={toolNotion as any}
footer={
<CardMoreInfo downloadCount={1234} tags={['Search', 'Productivity']} />
}
/>
<Card
payload={toolNotion as any}
footer={
<CardMoreInfo downloadCount={1234} tags={['Search', 'Productivity']} />
}
/>
<Card
payload={toolNotion as any}
footer={
<CardMoreInfo downloadCount={1234} tags={['Search', 'Productivity']} />
}
/>
<Card
payload={toolNotion as any}
footer={
<CardMoreInfo downloadCount={1234} tags={['Search', 'Productivity']} />
}
/>
<Card
payload={toolNotion as any}
footer={
<CardMoreInfo downloadCount={1234} tags={['Search', 'Productivity']} />
}
/>
<Card
payload={toolNotion as any}
footer={
<CardMoreInfo downloadCount={1234} tags={['Search', 'Productivity']} />
}
/>
<Card
payload={toolNotion as any}
footer={
<CardMoreInfo downloadCount={1234} tags={['Search', 'Productivity']} />
}
/>
<Card
payload={toolNotion as any}
footer={
<CardMoreInfo downloadCount={1234} tags={['Search', 'Productivity']} />
}
/>
<Card
payload={toolNotion as any}
footer={
<CardMoreInfo downloadCount={1234} tags={['Search', 'Productivity']} />
}
/>
<Card
payload={toolNotion as any}
footer={
<CardMoreInfo downloadCount={1234} tags={['Search', 'Productivity']} />
}
/>
<Card
payload={toolNotion as any}
footer={
<CardMoreInfo downloadCount={1234} tags={['Search', 'Productivity']} />
}
/>
<Card
payload={toolNotion as any}
footer={
<CardMoreInfo downloadCount={1234} tags={['Search', 'Productivity']} />
}
/>
<Card
payload={toolNotion as any}
footer={
<CardMoreInfo downloadCount={1234} tags={['Search', 'Productivity']} />
}
/>
<Card
payload={toolNotion as any}
footer={
<CardMoreInfo downloadCount={1234} tags={['Search', 'Productivity']} />
}
/>
<Card
payload={toolNotion as any}
footer={
<CardMoreInfo downloadCount={1234} tags={['Search', 'Productivity']} />
}
/>
<Card
payload={toolNotion as any}
footer={
<CardMoreInfo downloadCount={1234} tags={['Search', 'Productivity']} />
}
/>
<Card
payload={toolNotion as any}
footer={
<CardMoreInfo downloadCount={1234} tags={['Search', 'Productivity']} />
}
/>
<Card
payload={toolNotion as any}
footer={
<CardMoreInfo downloadCount={1234} tags={['Search', 'Productivity']} />
}
/>
<Card
payload={toolNotion as any}
footer={
<CardMoreInfo downloadCount={1234} tags={['Search', 'Productivity']} />
}
/>
<Card
payload={toolNotion as any}
footer={
<CardMoreInfo downloadCount={1234} tags={['Search', 'Productivity']} />
}
/>
<Card
payload={toolNotion as any}
footer={
<CardMoreInfo downloadCount={1234} tags={['Search', 'Productivity']} />
}
/>
<Card
payload={toolNotion as any}
footer={
<CardMoreInfo downloadCount={1234} tags={['Search', 'Productivity']} />
}
/>
<Card
payload={toolNotion as any}
footer={
<CardMoreInfo downloadCount={1234} tags={['Search', 'Productivity']} />
}
/>
</div>
</div>
</div>
)
}
export default Marketplace

View File

@ -0,0 +1,35 @@
import {
useCallback,
useEffect,
useState,
} from 'react'
import type { Plugin } from '@/app/components/plugins/types'
import type { MarketplaceCollection } from '@/app/components/plugins/marketplace/types'
export const useMarketplace = () => {
const [marketplaceCollections, setMarketplaceCollections] = useState<MarketplaceCollection[]>([])
const [marketplaceCollectionPluginsMap, setMarketplaceCollectionPluginsMap] = useState<Record<string, Plugin[]>>({})
const getMarketplaceCollections = useCallback(async () => {
const marketplaceCollectionsData = await globalThis.fetch('https://marketplace.dify.dev/api/v1/collections')
const marketplaceCollectionsDataJson = await marketplaceCollectionsData.json()
const marketplaceCollections = marketplaceCollectionsDataJson.data.collections
const marketplaceCollectionPluginsMap = {} as Record<string, Plugin[]>
await Promise.all(marketplaceCollections.map(async (collection: MarketplaceCollection) => {
const marketplaceCollectionPluginsData = await globalThis.fetch(`https://marketplace.dify.dev/api/v1/collections/${collection.name}/plugins`)
const marketplaceCollectionPluginsDataJson = await marketplaceCollectionPluginsData.json()
const plugins = marketplaceCollectionPluginsDataJson.data.plugins
marketplaceCollectionPluginsMap[collection.name] = plugins
}))
setMarketplaceCollections(marketplaceCollections)
setMarketplaceCollectionPluginsMap(marketplaceCollectionPluginsMap)
}, [])
useEffect(() => {
getMarketplaceCollections()
}, [getMarketplaceCollections])
return {
marketplaceCollections,
marketplaceCollectionPluginsMap,
}
}

View File

@ -0,0 +1,48 @@
import { RiArrowUpDoubleLine } from '@remixicon/react'
import { useMarketplace } from './hooks'
import List from '@/app/components/plugins/marketplace/list'
type MarketplaceProps = {
onMarketplaceScroll: () => void
}
const Marketplace = ({
onMarketplaceScroll,
}: MarketplaceProps) => {
const { marketplaceCollections, marketplaceCollectionPluginsMap } = useMarketplace()
return (
<div className='shrink-0 sticky -bottom-[442px] h-[530px] overflow-y-auto px-12 py-2 pt-0 bg-background-default-subtle'>
<RiArrowUpDoubleLine
className='absolute top-2 left-1/2 -translate-x-1/2 w-4 h-4 text-text-quaternary cursor-pointer'
onClick={() => onMarketplaceScroll()}
/>
<div className='sticky top-0 pt-5 pb-3 bg-background-default-subtle z-10'>
<div className='title-2xl-semi-bold bg-gradient-to-r from-[rgba(11,165,236,0.95)] to-[rgba(21,90,239,0.95)] bg-clip-text text-transparent'>More from Marketplace</div>
<div className='flex items-center text-center body-md-regular text-text-tertiary'>
Discover
<span className="relative ml-1 body-md-medium text-text-secondary after:content-[''] after:absolute after:left-0 after:bottom-[1.5px] after:w-full after:h-2 after:bg-text-text-selected">
models
</span>
,
<span className="relative ml-1 body-md-medium text-text-secondary after:content-[''] after:absolute after:left-0 after:bottom-[1.5px] after:w-full after:h-2 after:bg-text-text-selected">
tools
</span>
,
<span className="relative ml-1 mr-1 body-md-medium text-text-secondary after:content-[''] after:absolute after:left-0 after:bottom-[1.5px] after:w-full after:h-2 after:bg-text-text-selected">
extensions
</span>
and
<span className="relative ml-1 mr-1 body-md-medium text-text-secondary after:content-[''] after:absolute after:left-0 after:bottom-[1.5px] after:w-full after:h-2 after:bg-text-text-selected">
bundles
</span>
in Dify Marketplace
</div>
</div>
<List
marketplaceCollections={marketplaceCollections}
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap}
/>
</div>
)
}
export default Marketplace