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 {
useCallback,
useState,
} from 'react'
import {
createContext,
useContextSelector,
} 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 = {
intersected: boolean
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>({
intersected: true,
setIntersected: () => {},
searchPluginText: '',
handleSearchPluginTextChange: () => {},
filterPluginTags: [],
handleFilterPluginTagsChange: () => {},
activePluginType: PLUGIN_TYPE_SEARCH_MAP.all,
handleActivePluginTypeChange: () => {},
plugins: undefined,
setPlugins: () => {},
})
type MarketplaceContextProviderProps = {
@ -31,12 +52,69 @@ export const MarketplaceContextProvider = ({
children,
}: MarketplaceContextProviderProps) => {
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 (
<MarketplaceContext.Provider
value={{
intersected,
setIntersected,
searchPluginText,
handleSearchPluginTextChange,
filterPluginTags,
handleFilterPluginTagsChange,
activePluginType,
handleActivePluginTypeChange,
plugins,
setPlugins,
}}
>
{children}

View File

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

View File

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

View File

@ -1,25 +1,22 @@
'use client'
import { useState } from 'react'
import { PluginType } from '../types'
import {
RiArchive2Line,
RiBrain2Line,
RiHammerLine,
RiPuzzle2Line,
} from '@remixicon/react'
import { PluginType } from '../types'
import { useMarketplaceContext } from './context'
import cn from '@/utils/classnames'
const PLUGIN_TYPE_SEARCH_MAP = {
export const PLUGIN_TYPE_SEARCH_MAP = {
all: 'all',
model: PluginType.model,
tool: PluginType.tool,
extension: PluginType.extension,
bundle: 'bundle',
}
type PluginTypeSwitchProps = {
onChange?: (type: string) => void
}
const options = [
{
value: PLUGIN_TYPE_SEARCH_MAP.all,
@ -47,10 +44,9 @@ const options = [
icon: <RiArchive2Line className='mr-1.5 w-4 h-4' />,
},
]
const PluginTypeSwitch = ({
onChange,
}: PluginTypeSwitchProps) => {
const [activeType, setActiveType] = useState(PLUGIN_TYPE_SEARCH_MAP.all)
const PluginTypeSwitch = () => {
const activePluginType = useMarketplaceContext(s => s.activePluginType)
const handleActivePluginTypeChange = useMarketplaceContext(s => s.handleActivePluginTypeChange)
return (
<div className={cn(
@ -62,11 +58,10 @@ const PluginTypeSwitch = ({
key={option.value}
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',
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={() => {
setActiveType(option.value)
onChange?.(option.value)
handleActivePluginTypeChange(option.value)
}}
>
{option.icon}

View File

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

View File

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

View File

@ -17,3 +17,13 @@ export type MarketplaceCollectionPluginsResponse = {
plugins: Plugin[]
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