mirror of
https://github.com/langgenius/dify.git
synced 2026-02-10 18:34:08 +00:00
Compare commits
15 Commits
deploy/age
...
feat/marke
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c6da34539 | ||
|
|
9f8289b185 | ||
|
|
56c5739e4e | ||
|
|
b241122cf7 | ||
|
|
984992d0fd | ||
|
|
b9cd625c53 | ||
|
|
f96fbbe03a | ||
|
|
9a698eaad9 | ||
|
|
6329647f3b | ||
|
|
063459599c | ||
|
|
a59023f75b | ||
|
|
41c1d981a1 | ||
|
|
3f5037f911 | ||
|
|
cbbb05c189 | ||
|
|
1ce8c43e2c |
@@ -6,7 +6,7 @@ const PluginList = () => {
|
||||
return (
|
||||
<PluginPage
|
||||
plugins={<PluginsPanel />}
|
||||
marketplace={<Marketplace pluginTypeSwitchClassName="top-[60px]" />}
|
||||
marketplace={<Marketplace />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.6301 11.3333C11.6301 12.4379 10.7347 13.3333 9.63013 13.3333C8.52559 13.3333 7.63013 12.4379 7.63013 11.3333C7.63013 10.2287 8.52559 9.33325 9.63013 9.33325C10.7347 9.33325 11.6301 10.2287 11.6301 11.3333Z" stroke="white" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<path d="M3.1353 4.75464L6.67352 7.72353L2.33325 9.30327L3.1353 4.75464Z" stroke="white" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<path d="M9.79576 2.5L13.6595 3.53527L12.6242 7.399L8.7605 6.36371L9.79576 2.5Z" stroke="white" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 658 B |
@@ -0,0 +1,5 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2.5 6.66675H17.5V15.8334H2.5V6.66675Z" stroke="#354052" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4.16675 6.66659V3.33325H8.33341V6.66659" stroke="#354052" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M11.6667 6.66659V3.33325H15.8334V6.66659" stroke="#354052" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 509 B |
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"width": "16",
|
||||
"height": "16",
|
||||
"viewBox": "0 0 16 16",
|
||||
"fill": "none",
|
||||
"xmlns": "http://www.w3.org/2000/svg"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M11.6301 11.3333C11.6301 12.4379 10.7347 13.3333 9.63013 13.3333C8.52559 13.3333 7.63013 12.4379 7.63013 11.3333C7.63013 10.2287 8.52559 9.33325 9.63013 9.33325C10.7347 9.33325 11.6301 10.2287 11.6301 11.3333Z",
|
||||
"stroke": "currentColor",
|
||||
"stroke-width": "1.5",
|
||||
"stroke-linejoin": "round"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M3.1353 4.75464L6.67352 7.72353L2.33325 9.30327L3.1353 4.75464Z",
|
||||
"stroke": "currentColor",
|
||||
"stroke-width": "1.5",
|
||||
"stroke-linejoin": "round"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M9.79576 2.5L13.6595 3.53527L12.6242 7.399L8.7605 6.36371L9.79576 2.5Z",
|
||||
"stroke": "currentColor",
|
||||
"stroke-width": "1.5",
|
||||
"stroke-linejoin": "round"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "Playground"
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
import * as React from 'react'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import data from './Playground.json'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'Playground'
|
||||
|
||||
export default Icon
|
||||
53
web/app/components/base/icons/src/vender/plugin/Plugin.json
Normal file
53
web/app/components/base/icons/src/vender/plugin/Plugin.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"width": "20",
|
||||
"height": "20",
|
||||
"viewBox": "0 0 20 20",
|
||||
"fill": "none",
|
||||
"xmlns": "http://www.w3.org/2000/svg"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M2.5 6.66675H17.5V15.8334H2.5V6.66675Z",
|
||||
"stroke": "currentColor",
|
||||
"stroke-width": "1.5",
|
||||
"stroke-linecap": "round",
|
||||
"stroke-linejoin": "round"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M4.16675 6.66659V3.33325H8.33341V6.66659",
|
||||
"stroke": "currentColor",
|
||||
"stroke-width": "1.5",
|
||||
"stroke-linecap": "round",
|
||||
"stroke-linejoin": "round"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M11.6667 6.66659V3.33325H15.8334V6.66659",
|
||||
"stroke": "currentColor",
|
||||
"stroke-width": "1.5",
|
||||
"stroke-linecap": "round",
|
||||
"stroke-linejoin": "round"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "Plugin"
|
||||
}
|
||||
20
web/app/components/base/icons/src/vender/plugin/Plugin.tsx
Normal file
20
web/app/components/base/icons/src/vender/plugin/Plugin.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
import * as React from 'react'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import data from './Plugin.json'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'Plugin'
|
||||
|
||||
export default Icon
|
||||
@@ -1,3 +1,5 @@
|
||||
export { default as BoxSparkleFill } from './BoxSparkleFill'
|
||||
export { default as LeftCorner } from './LeftCorner'
|
||||
export { default as Playground } from './Playground'
|
||||
export { default as Plugin } from './Plugin'
|
||||
export { default as Trigger } from './Trigger'
|
||||
|
||||
@@ -11,7 +11,7 @@ const Icon = (
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
|
||||
@@ -64,8 +64,8 @@ const InstallFromMarketplace = ({
|
||||
{
|
||||
!isAllPluginsLoading && !collapse && (
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={allPlugins}
|
||||
showInstallButton
|
||||
cardContainerClassName="grid grid-cols-2 gap-2"
|
||||
|
||||
@@ -63,8 +63,8 @@ const InstallFromMarketplace = ({
|
||||
{
|
||||
!isAllPluginsLoading && !collapse && (
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={allPlugins}
|
||||
showInstallButton
|
||||
cardContainerClassName="grid grid-cols-2 gap-2"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RiInstallLine } from '@remixicon/react'
|
||||
import { useTranslation } from '#i18n'
|
||||
import * as React from 'react'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
|
||||
@@ -9,10 +9,13 @@ type Props = {
|
||||
const DownloadCountComponent = ({
|
||||
downloadCount,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation('plugin')
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-1 text-text-tertiary">
|
||||
<RiInstallLine className="h-3 w-3 shrink-0" />
|
||||
<div className="system-xs-regular">{formatNumber(downloadCount)}</div>
|
||||
<div className="system-xs-regular text-text-tertiary">
|
||||
{formatNumber(downloadCount)}
|
||||
{' '}
|
||||
{t('marketplace.installs')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import Link from 'next/link'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import DownloadCount from './download-count'
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
orgName?: string
|
||||
packageName: string
|
||||
packageName?: string
|
||||
packageNameClassName?: string
|
||||
downloadCount?: number
|
||||
linkToOrg?: boolean
|
||||
}
|
||||
|
||||
const OrgInfo = ({
|
||||
@@ -12,7 +16,42 @@ const OrgInfo = ({
|
||||
orgName,
|
||||
packageName,
|
||||
packageNameClassName,
|
||||
downloadCount,
|
||||
linkToOrg = true,
|
||||
}: Props) => {
|
||||
// New format: "by {orgName} · {downloadCount} installs" (for marketplace cards)
|
||||
if (downloadCount !== undefined) {
|
||||
return (
|
||||
<div className={cn('system-xs-regular flex h-4 items-center gap-2 text-text-tertiary', className)}>
|
||||
{orgName && (
|
||||
<span className="shrink-0">
|
||||
<span className="mr-1 text-text-tertiary">by</span>
|
||||
{linkToOrg
|
||||
? (
|
||||
<Link
|
||||
href={`/creators/${orgName}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-text-secondary hover:underline"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{orgName}
|
||||
</Link>
|
||||
)
|
||||
: (
|
||||
<span className="text-text-tertiary">
|
||||
{orgName}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<span className="shrink-0">·</span>
|
||||
<DownloadCount downloadCount={downloadCount} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Legacy format: "{orgName} / {packageName}" (for plugin detail panels)
|
||||
return (
|
||||
<div className={cn('flex h-4 items-center space-x-0.5', className)}>
|
||||
{orgName && (
|
||||
@@ -21,9 +60,11 @@ const OrgInfo = ({
|
||||
<span className="system-xs-regular shrink-0 text-text-quaternary">/</span>
|
||||
</>
|
||||
)}
|
||||
<span className={cn('system-xs-regular w-0 shrink-0 grow truncate text-text-tertiary', packageNameClassName)}>
|
||||
{packageName}
|
||||
</span>
|
||||
{packageName && (
|
||||
<span className={cn('system-xs-regular w-0 shrink-0 grow truncate text-text-tertiary', packageNameClassName)}>
|
||||
{packageName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import DownloadCount from './base/download-count'
|
||||
|
||||
type Props = {
|
||||
downloadCount?: number
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
const CardMoreInfoComponent = ({
|
||||
downloadCount,
|
||||
tags,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className="flex h-5 items-center">
|
||||
{downloadCount !== undefined && <DownloadCount downloadCount={downloadCount} />}
|
||||
{downloadCount !== undefined && tags && tags.length > 0 && <div className="system-xs-regular mx-2 text-text-quaternary">·</div>}
|
||||
{tags && tags.length > 0 && (
|
||||
<>
|
||||
<div className="flex h-4 flex-wrap space-x-2 overflow-hidden">
|
||||
{tags.map(tag => (
|
||||
<div
|
||||
key={tag}
|
||||
className="system-xs-regular flex max-w-[120px] space-x-1 overflow-hidden"
|
||||
title={`# ${tag}`}
|
||||
>
|
||||
<span className="text-text-quaternary">#</span>
|
||||
<span className="truncate text-text-tertiary">{tag}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Memoize to prevent unnecessary re-renders when tags array hasn't changed
|
||||
const CardMoreInfo = React.memo(CardMoreInfoComponent)
|
||||
|
||||
export default CardMoreInfo
|
||||
34
web/app/components/plugins/card/card-tags.tsx
Normal file
34
web/app/components/plugins/card/card-tags.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { RiPriceTag3Line } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
|
||||
type Props = {
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
const CardTagsComponent = ({
|
||||
tags,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className="mt-2 flex min-h-[20px] items-center gap-1">
|
||||
{tags && tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 overflow-hidden">
|
||||
{tags.slice(0, 2).map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex max-w-[100px] items-center gap-0.5 truncate rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-[5px] py-[3px]"
|
||||
title={tag}
|
||||
>
|
||||
<RiPriceTag3Line className="h-3 w-3 shrink-0 text-text-quaternary" />
|
||||
<span className="system-2xs-medium-uppercase text-text-tertiary">{tag.toUpperCase()}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Memoize to prevent unnecessary re-renders when tags array hasn't changed
|
||||
const CardTags = React.memo(CardTagsComponent)
|
||||
|
||||
export default CardTags
|
||||
@@ -11,7 +11,7 @@ import DownloadCount from './base/download-count'
|
||||
import OrgInfo from './base/org-info'
|
||||
import Placeholder, { LoadingPlaceholder } from './base/placeholder'
|
||||
import Title from './base/title'
|
||||
import CardMoreInfo from './card-more-info'
|
||||
import CardTags from './card-tags'
|
||||
// ================================
|
||||
// Import Components Under Test
|
||||
// ================================
|
||||
@@ -642,9 +642,9 @@ describe('Card', () => {
|
||||
})
|
||||
|
||||
// ================================
|
||||
// CardMoreInfo Component Tests
|
||||
// CardTags Component Tests
|
||||
// ================================
|
||||
describe('CardMoreInfo', () => {
|
||||
describe('CardTags', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
@@ -654,66 +654,24 @@ describe('CardMoreInfo', () => {
|
||||
// ================================
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<CardMoreInfo downloadCount={100} tags={['tag1']} />)
|
||||
render(<CardTags tags={['tag1']} />)
|
||||
|
||||
expect(document.body).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render download count when provided', () => {
|
||||
render(<CardMoreInfo downloadCount={1000} tags={[]} />)
|
||||
it('should render tags in uppercase', () => {
|
||||
render(<CardTags tags={['search', 'image']} />)
|
||||
|
||||
expect(screen.getByText('1,000')).toBeInTheDocument()
|
||||
expect(screen.getByText('SEARCH')).toBeInTheDocument()
|
||||
expect(screen.getByText('IMAGE')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render tags when provided', () => {
|
||||
render(<CardMoreInfo tags={['search', 'image']} />)
|
||||
it('should render at most two tags', () => {
|
||||
render(<CardTags tags={['one', 'two', 'three']} />)
|
||||
|
||||
expect(screen.getByText('search')).toBeInTheDocument()
|
||||
expect(screen.getByText('image')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render both download count and tags with separator', () => {
|
||||
render(<CardMoreInfo downloadCount={500} tags={['tag1']} />)
|
||||
|
||||
expect(screen.getByText('500')).toBeInTheDocument()
|
||||
expect(screen.getByText('·')).toBeInTheDocument()
|
||||
expect(screen.getByText('tag1')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Props Testing
|
||||
// ================================
|
||||
describe('Props', () => {
|
||||
it('should not render download count when undefined', () => {
|
||||
render(<CardMoreInfo tags={['tag1']} />)
|
||||
|
||||
expect(screen.queryByTestId('ri-install-line')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render separator when download count is undefined', () => {
|
||||
render(<CardMoreInfo tags={['tag1']} />)
|
||||
|
||||
expect(screen.queryByText('·')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render separator when tags are empty', () => {
|
||||
render(<CardMoreInfo downloadCount={100} tags={[]} />)
|
||||
|
||||
expect(screen.queryByText('·')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render hash symbol before each tag', () => {
|
||||
render(<CardMoreInfo tags={['search']} />)
|
||||
|
||||
expect(screen.getByText('#')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should set title attribute with hash prefix for tags', () => {
|
||||
render(<CardMoreInfo tags={['search']} />)
|
||||
|
||||
const tagElement = screen.getByTitle('# search')
|
||||
expect(tagElement).toBeInTheDocument()
|
||||
expect(screen.getByText('ONE')).toBeInTheDocument()
|
||||
expect(screen.getByText('TWO')).toBeInTheDocument()
|
||||
expect(screen.queryByText('THREE')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -722,54 +680,8 @@ describe('CardMoreInfo', () => {
|
||||
// ================================
|
||||
describe('Memoization', () => {
|
||||
it('should be memoized with React.memo', () => {
|
||||
expect(CardMoreInfo).toBeDefined()
|
||||
expect(typeof CardMoreInfo).toBe('object')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Edge Cases Tests
|
||||
// ================================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle zero download count', () => {
|
||||
render(<CardMoreInfo downloadCount={0} tags={[]} />)
|
||||
|
||||
// 0 should still render since downloadCount is defined
|
||||
expect(screen.getByText('0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty tags array', () => {
|
||||
render(<CardMoreInfo downloadCount={100} tags={[]} />)
|
||||
|
||||
expect(screen.queryByText('#')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle large download count', () => {
|
||||
render(<CardMoreInfo downloadCount={1234567890} tags={[]} />)
|
||||
|
||||
expect(screen.getByText('1,234,567,890')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle many tags', () => {
|
||||
const tags = Array.from({ length: 10 }, (_, i) => `tag${i}`)
|
||||
render(<CardMoreInfo downloadCount={100} tags={tags} />)
|
||||
|
||||
expect(screen.getByText('tag0')).toBeInTheDocument()
|
||||
expect(screen.getByText('tag9')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle tags with special characters', () => {
|
||||
render(<CardMoreInfo tags={['tag-with-dash', 'tag_with_underscore']} />)
|
||||
|
||||
expect(screen.getByText('tag-with-dash')).toBeInTheDocument()
|
||||
expect(screen.getByText('tag_with_underscore')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should truncate long tag names', () => {
|
||||
const longTag = 'a'.repeat(200)
|
||||
const { container } = render(<CardMoreInfo tags={[longTag]} />)
|
||||
|
||||
expect(container.querySelector('.truncate')).toBeInTheDocument()
|
||||
expect(CardTags).toBeDefined()
|
||||
expect(typeof CardTags).toBe('object')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1688,7 +1600,7 @@ describe('Icon', () => {
|
||||
render(
|
||||
<Card
|
||||
payload={plugin}
|
||||
footer={<CardMoreInfo downloadCount={5000} tags={['search', 'api']} />}
|
||||
footer={<CardTags tags={['search', 'api']} />}
|
||||
/>,
|
||||
)
|
||||
|
||||
@@ -1700,9 +1612,8 @@ describe('Icon', () => {
|
||||
expect(screen.getByText('Tool')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('partner-badge')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('verified-badge')).toBeInTheDocument()
|
||||
expect(screen.getByText('5,000')).toBeInTheDocument()
|
||||
expect(screen.getByText('search')).toBeInTheDocument()
|
||||
expect(screen.getByText('api')).toBeInTheDocument()
|
||||
expect(screen.getByText('SEARCH')).toBeInTheDocument()
|
||||
expect(screen.getByText('API')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render loading state correctly', () => {
|
||||
@@ -1728,12 +1639,12 @@ describe('Icon', () => {
|
||||
<Card
|
||||
payload={plugin}
|
||||
installed={true}
|
||||
footer={<CardMoreInfo downloadCount={100} tags={['tag1']} />}
|
||||
footer={<CardTags tags={['tag1']} />}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('ri-check-line')).toBeInTheDocument()
|
||||
expect(screen.getByText('100')).toBeInTheDocument()
|
||||
expect(screen.getByText('TAG1')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1817,9 +1728,9 @@ describe('Icon', () => {
|
||||
})
|
||||
|
||||
it('should have title attribute on tags', () => {
|
||||
render(<CardMoreInfo downloadCount={100} tags={['search']} />)
|
||||
render(<CardTags tags={['search']} />)
|
||||
|
||||
expect(screen.getByTitle('# search')).toBeInTheDocument()
|
||||
expect(screen.getByTitle('search')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have semantic structure', () => {
|
||||
@@ -1864,11 +1775,11 @@ describe('Icon', () => {
|
||||
expect(endTime - startTime).toBeLessThan(1000)
|
||||
})
|
||||
|
||||
it('should handle CardMoreInfo with many tags', () => {
|
||||
it('should handle CardTags with many tags', () => {
|
||||
const tags = Array.from({ length: 20 }, (_, i) => `tag-${i}`)
|
||||
|
||||
const startTime = performance.now()
|
||||
render(<CardMoreInfo downloadCount={1000} tags={tags} />)
|
||||
render(<CardTags tags={tags} />)
|
||||
const endTime = performance.now()
|
||||
|
||||
expect(endTime - startTime).toBeLessThan(100)
|
||||
|
||||
@@ -32,6 +32,7 @@ export type Props = {
|
||||
isLoading?: boolean
|
||||
loadingFileName?: string
|
||||
limitedInstall?: boolean
|
||||
disableOrgLink?: boolean
|
||||
}
|
||||
|
||||
const Card = ({
|
||||
@@ -46,11 +47,12 @@ const Card = ({
|
||||
isLoading = false,
|
||||
loadingFileName,
|
||||
limitedInstall = false,
|
||||
disableOrgLink = false,
|
||||
}: Props) => {
|
||||
const locale = useGetLanguage()
|
||||
const { t } = useTranslation()
|
||||
const { categoriesMap } = useCategories(true)
|
||||
const { category, type, name, org, label, brief, icon, icon_dark, verified, badges = [] } = payload
|
||||
const { category, type, org, label, brief, icon, icon_dark, verified, badges = [], install_count } = payload
|
||||
const { theme } = useTheme()
|
||||
const iconSrc = theme === Theme.dark && icon_dark ? icon_dark : icon
|
||||
const getLocalizedText = (obj: Record<string, string> | undefined) =>
|
||||
@@ -86,7 +88,8 @@ const Card = ({
|
||||
<OrgInfo
|
||||
className="mt-0.5"
|
||||
orgName={org}
|
||||
packageName={name}
|
||||
downloadCount={install_count}
|
||||
linkToOrg={!disableOrgLink}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { SearchTab } from './search-params'
|
||||
import type { PluginsSort, SearchParamsFromCollection } from './types'
|
||||
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import { useCallback } from 'react'
|
||||
import { DEFAULT_SORT, PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants'
|
||||
import { marketplaceSearchParamsParsers } from './search-params'
|
||||
import { DEFAULT_SORT, getValidatedPluginCategory, getValidatedTemplateCategory, PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants'
|
||||
import { CREATION_TYPE, marketplaceSearchParamsParsers } from './search-params'
|
||||
|
||||
const marketplaceSortAtom = atom<PluginsSort>(DEFAULT_SORT)
|
||||
export function useMarketplaceSort() {
|
||||
@@ -16,16 +17,30 @@ export function useSetMarketplaceSort() {
|
||||
return useSetAtom(marketplaceSortAtom)
|
||||
}
|
||||
|
||||
export function useSearchPluginText() {
|
||||
export function useSearchText() {
|
||||
return useQueryState('q', marketplaceSearchParamsParsers.q)
|
||||
}
|
||||
export function useActivePluginType() {
|
||||
return useQueryState('category', marketplaceSearchParamsParsers.category)
|
||||
export function useActivePluginCategory() {
|
||||
const [category, setCategory] = useQueryState('category', marketplaceSearchParamsParsers.category)
|
||||
return [getValidatedPluginCategory(category), setCategory] as const
|
||||
}
|
||||
|
||||
export function useActiveTemplateCategory() {
|
||||
const [category, setCategory] = useQueryState('category', marketplaceSearchParamsParsers.category)
|
||||
return [getValidatedTemplateCategory(category), setCategory] as const
|
||||
}
|
||||
export function useFilterPluginTags() {
|
||||
return useQueryState('tags', marketplaceSearchParamsParsers.tags)
|
||||
}
|
||||
|
||||
export function useSearchTab() {
|
||||
return useQueryState('searchTab', marketplaceSearchParamsParsers.searchTab)
|
||||
}
|
||||
|
||||
export function useCreationType() {
|
||||
return useQueryState('creationType', marketplaceSearchParamsParsers.creationType)
|
||||
}
|
||||
|
||||
/**
|
||||
* Not all categories have collections, so we need to
|
||||
* force the search mode for those categories.
|
||||
@@ -33,23 +48,27 @@ export function useFilterPluginTags() {
|
||||
export const searchModeAtom = atom<true | null>(null)
|
||||
|
||||
export function useMarketplaceSearchMode() {
|
||||
const [searchPluginText] = useSearchPluginText()
|
||||
const [creationType] = useCreationType()
|
||||
const [searchText] = useSearchText()
|
||||
const [searchTab] = useSearchTab()
|
||||
const [filterPluginTags] = useFilterPluginTags()
|
||||
const [activePluginType] = useActivePluginType()
|
||||
const [activePluginCategory] = useActivePluginCategory()
|
||||
const isPluginsView = creationType === CREATION_TYPE.plugins
|
||||
|
||||
const searchMode = useAtomValue(searchModeAtom)
|
||||
const isSearchMode = !!searchPluginText
|
||||
|| filterPluginTags.length > 0
|
||||
|| (searchMode ?? (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(activePluginType)))
|
||||
const isSearchMode = searchTab || searchText
|
||||
|| (isPluginsView && filterPluginTags.length > 0)
|
||||
|| (searchMode ?? (isPluginsView && !PLUGIN_CATEGORY_WITH_COLLECTIONS.has(activePluginCategory)))
|
||||
return isSearchMode
|
||||
}
|
||||
|
||||
export function useMarketplaceMoreClick() {
|
||||
const [,setQ] = useSearchPluginText()
|
||||
const [, setQ] = useSearchText()
|
||||
const [, setSearchTab] = useSearchTab()
|
||||
const setSort = useSetAtom(marketplaceSortAtom)
|
||||
const setSearchMode = useSetAtom(searchModeAtom)
|
||||
|
||||
return useCallback((searchParams?: SearchParamsFromCollection) => {
|
||||
return useCallback((searchParams?: SearchParamsFromCollection, searchTab?: SearchTab) => {
|
||||
if (!searchParams)
|
||||
return
|
||||
setQ(searchParams?.query || '')
|
||||
@@ -58,5 +77,7 @@ export function useMarketplaceMoreClick() {
|
||||
sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder,
|
||||
})
|
||||
setSearchMode(true)
|
||||
}, [setQ, setSort, setSearchMode])
|
||||
if (searchTab)
|
||||
setSearchTab(searchTab)
|
||||
}, [setQ, setSearchTab, setSort, setSearchMode])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
export type CategoryOption = {
|
||||
value: string
|
||||
text: string
|
||||
icon: React.ReactNode | null
|
||||
}
|
||||
|
||||
type CategorySwitchProps = {
|
||||
className?: string
|
||||
variant?: 'default' | 'hero'
|
||||
options: CategoryOption[]
|
||||
activeValue: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
export const CommonCategorySwitch = ({
|
||||
className,
|
||||
variant = 'default',
|
||||
options,
|
||||
activeValue,
|
||||
onChange,
|
||||
}: CategorySwitchProps) => {
|
||||
const isHeroVariant = variant === 'hero'
|
||||
|
||||
const getItemClassName = (isActive: boolean) => {
|
||||
if (isHeroVariant) {
|
||||
return cn(
|
||||
'system-md-medium flex h-8 cursor-pointer items-center rounded-lg px-3 text-text-primary-on-surface transition-all',
|
||||
isActive
|
||||
? 'bg-components-button-secondary-bg text-saas-dify-blue-inverted'
|
||||
: 'hover:bg-state-base-hover',
|
||||
)
|
||||
}
|
||||
return cn(
|
||||
'system-md-medium flex h-8 cursor-pointer items-center rounded-xl border border-transparent px-3 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
|
||||
isActive && 'border-components-main-nav-nav-button-border !bg-components-main-nav-nav-button-bg-active !text-components-main-nav-nav-button-text-active shadow-xs',
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center space-x-2',
|
||||
!isHeroVariant && 'justify-center bg-background-body py-3',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{
|
||||
options.map(option => (
|
||||
<div
|
||||
key={option.value}
|
||||
className={getItemClassName(activeValue === option.value)}
|
||||
onClick={() => onChange(option.value)}
|
||||
>
|
||||
{option.icon}
|
||||
{option.text}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslation } from '#i18n'
|
||||
import { useState } from 'react'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import Input from '@/app/components/base/input'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { useTags } from '@/app/components/plugins/hooks'
|
||||
import HeroTagsTrigger from './hero-tags-trigger'
|
||||
|
||||
type HeroTagsFilterProps = {
|
||||
tags: string[]
|
||||
onTagsChange: (tags: string[]) => void
|
||||
}
|
||||
|
||||
const HeroTagsFilter = ({
|
||||
tags,
|
||||
onTagsChange,
|
||||
}: HeroTagsFilterProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const { tags: options, tagsMap } = useTags()
|
||||
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)}
|
||||
>
|
||||
<HeroTagsTrigger
|
||||
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('searchTags', { ns: 'pluginTags' }) || ''}
|
||||
/>
|
||||
</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 HeroTagsFilter
|
||||
@@ -0,0 +1,86 @@
|
||||
'use client'
|
||||
|
||||
import type { Tag } from '../../hooks'
|
||||
import { useTranslation } from '#i18n'
|
||||
import { RiArrowDownSLine, RiCloseCircleFill, RiPriceTag3Line } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type HeroTagsTriggerProps = {
|
||||
selectedTagsLength: number
|
||||
open: boolean
|
||||
tags: string[]
|
||||
tagsMap: Record<string, Tag>
|
||||
onTagsChange: (tags: string[]) => void
|
||||
}
|
||||
|
||||
const HeroTagsTrigger = ({
|
||||
selectedTagsLength,
|
||||
open,
|
||||
tags,
|
||||
tagsMap,
|
||||
onTagsChange,
|
||||
}: HeroTagsTriggerProps) => {
|
||||
const { t } = useTranslation()
|
||||
const hasSelected = !!selectedTagsLength
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-8 cursor-pointer select-none items-center gap-1.5 rounded-lg px-2.5 py-1.5',
|
||||
!hasSelected && 'border border-white/30 text-text-primary-on-surface',
|
||||
!hasSelected && open && 'bg-white/10',
|
||||
!hasSelected && !open && 'hover:bg-white/10',
|
||||
hasSelected && 'border border-white bg-components-button-secondary-bg-hover shadow-md backdrop-blur-[5px]',
|
||||
)}
|
||||
>
|
||||
<RiPriceTag3Line className={cn(
|
||||
'size-4 shrink-0',
|
||||
hasSelected ? 'text-saas-dify-blue-inverted' : 'text-text-primary-on-surface',
|
||||
)}
|
||||
/>
|
||||
<div className="system-md-medium flex items-center gap-0.5">
|
||||
{
|
||||
!hasSelected && (
|
||||
<span>{t('allTags', { ns: 'pluginTags' })}</span>
|
||||
)
|
||||
}
|
||||
{
|
||||
hasSelected && (
|
||||
<span className="text-saas-dify-blue-inverted">
|
||||
{tags.map(tag => tagsMap[tag]?.label).filter(Boolean).slice(0, 2).join(', ')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
{
|
||||
selectedTagsLength > 2 && (
|
||||
<div className="flex min-w-4 items-center justify-center rounded-[5px] border border-saas-dify-blue-inverted px-1 py-0.5">
|
||||
<span className="system-2xs-medium-uppercase text-saas-dify-blue-inverted">
|
||||
+
|
||||
{selectedTagsLength - 2}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{
|
||||
hasSelected && (
|
||||
<RiCloseCircleFill
|
||||
className="size-4 shrink-0 text-saas-dify-blue-inverted"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onTagsChange([])
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!hasSelected && (
|
||||
<RiArrowDownSLine className="size-4 shrink-0 text-text-primary-on-surface" />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(HeroTagsTrigger)
|
||||
@@ -0,0 +1,4 @@
|
||||
'use client'
|
||||
|
||||
export { PluginCategorySwitch } from './plugin'
|
||||
export { TemplateCategorySwitch } from './template'
|
||||
@@ -0,0 +1,120 @@
|
||||
'use client'
|
||||
|
||||
import type { ActivePluginType } from '../constants'
|
||||
import type { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import { useTranslation } from '#i18n'
|
||||
import { RiArchive2Line } from '@remixicon/react'
|
||||
import { useSetAtom } from 'jotai'
|
||||
import { Plugin } from '@/app/components/base/icons/src/vender/plugin'
|
||||
import { searchModeAtom, useActivePluginCategory, useFilterPluginTags } from '../atoms'
|
||||
import { PLUGIN_CATEGORY_WITH_COLLECTIONS, PLUGIN_TYPE_SEARCH_MAP } from '../constants'
|
||||
import { MARKETPLACE_TYPE_ICON_COMPONENTS } from '../plugin-type-icons'
|
||||
import { CommonCategorySwitch } from './common'
|
||||
import HeroTagsFilter from './hero-tags-filter'
|
||||
|
||||
type PluginTypeSwitchProps = {
|
||||
className?: string
|
||||
variant?: 'default' | 'hero'
|
||||
}
|
||||
|
||||
const getTypeIcon = (value: ActivePluginType, isHeroVariant?: boolean) => {
|
||||
if (value === PLUGIN_TYPE_SEARCH_MAP.all)
|
||||
return isHeroVariant ? <Plugin className="mr-1.5 h-4 w-4" /> : null
|
||||
if (value === PLUGIN_TYPE_SEARCH_MAP.bundle)
|
||||
return <RiArchive2Line className="mr-1.5 h-4 w-4" />
|
||||
const Icon = MARKETPLACE_TYPE_ICON_COMPONENTS[value as PluginCategoryEnum]
|
||||
return Icon ? <Icon className="mr-1.5 h-4 w-4" /> : null
|
||||
}
|
||||
|
||||
export const PluginCategorySwitch = ({
|
||||
className,
|
||||
variant = 'default',
|
||||
}: PluginTypeSwitchProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [activePluginCategory, handleActivePluginCategoryChange] = useActivePluginCategory()
|
||||
const [filterPluginTags, setFilterPluginTags] = useFilterPluginTags()
|
||||
const setSearchMode = useSetAtom(searchModeAtom)
|
||||
|
||||
const isHeroVariant = variant === 'hero'
|
||||
|
||||
const options = [
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.all,
|
||||
text: isHeroVariant ? t('category.allTypes', { ns: 'plugin' }) : t('category.all', { ns: 'plugin' }),
|
||||
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.all, isHeroVariant),
|
||||
},
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.model,
|
||||
text: t('category.models', { ns: 'plugin' }),
|
||||
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.model, isHeroVariant),
|
||||
},
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.tool,
|
||||
text: t('category.tools', { ns: 'plugin' }),
|
||||
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.tool, isHeroVariant),
|
||||
},
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.datasource,
|
||||
text: t('category.datasources', { ns: 'plugin' }),
|
||||
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.datasource, isHeroVariant),
|
||||
},
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.trigger,
|
||||
text: t('category.triggers', { ns: 'plugin' }),
|
||||
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.trigger, isHeroVariant),
|
||||
},
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.agent,
|
||||
text: t('category.agents', { ns: 'plugin' }),
|
||||
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.agent, isHeroVariant),
|
||||
},
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.extension,
|
||||
text: t('category.extensions', { ns: 'plugin' }),
|
||||
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.extension, isHeroVariant),
|
||||
},
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.bundle,
|
||||
text: t('category.bundles', { ns: 'plugin' }),
|
||||
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.bundle, isHeroVariant),
|
||||
},
|
||||
]
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
handleActivePluginCategoryChange(value)
|
||||
if (PLUGIN_CATEGORY_WITH_COLLECTIONS.has(value as ActivePluginType)) {
|
||||
setSearchMode(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isHeroVariant) {
|
||||
return (
|
||||
<CommonCategorySwitch
|
||||
className={className}
|
||||
variant={variant}
|
||||
options={options}
|
||||
activeValue={activePluginCategory}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<HeroTagsFilter
|
||||
tags={filterPluginTags}
|
||||
onTagsChange={tags => setFilterPluginTags(tags.length ? tags : null)}
|
||||
/>
|
||||
<div className="text-text-primary-on-surface">
|
||||
·
|
||||
</div>
|
||||
<CommonCategorySwitch
|
||||
className={className}
|
||||
variant={variant}
|
||||
options={options}
|
||||
activeValue={activePluginCategory}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslation } from '#i18n'
|
||||
import { Playground } from '@/app/components/base/icons/src/vender/plugin'
|
||||
import { useActiveTemplateCategory } from '../atoms'
|
||||
import { CATEGORY_ALL, TEMPLATE_CATEGORY_MAP } from '../constants'
|
||||
import { CommonCategorySwitch } from './common'
|
||||
|
||||
type TemplateCategorySwitchProps = {
|
||||
className?: string
|
||||
variant?: 'default' | 'hero'
|
||||
}
|
||||
|
||||
export const TemplateCategorySwitch = ({
|
||||
className,
|
||||
variant = 'default',
|
||||
}: TemplateCategorySwitchProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [activeTemplateCategory, handleActiveTemplateCategoryChange] = useActiveTemplateCategory()
|
||||
|
||||
const isHeroVariant = variant === 'hero'
|
||||
|
||||
const options = [
|
||||
{
|
||||
value: CATEGORY_ALL,
|
||||
text: t('marketplace.templateCategory.all', { ns: 'plugin' }),
|
||||
icon: isHeroVariant ? <Playground className="mr-1.5 h-4 w-4" /> : null,
|
||||
},
|
||||
{
|
||||
value: TEMPLATE_CATEGORY_MAP.marketing,
|
||||
text: t('marketplace.templateCategory.marketing', { ns: 'plugin' }),
|
||||
icon: null,
|
||||
},
|
||||
{
|
||||
value: TEMPLATE_CATEGORY_MAP.sales,
|
||||
text: t('marketplace.templateCategory.sales', { ns: 'plugin' }),
|
||||
icon: null,
|
||||
},
|
||||
{
|
||||
value: TEMPLATE_CATEGORY_MAP.support,
|
||||
text: t('marketplace.templateCategory.support', { ns: 'plugin' }),
|
||||
icon: null,
|
||||
},
|
||||
{
|
||||
value: TEMPLATE_CATEGORY_MAP.operations,
|
||||
text: t('marketplace.templateCategory.operations', { ns: 'plugin' }),
|
||||
icon: null,
|
||||
},
|
||||
{
|
||||
value: TEMPLATE_CATEGORY_MAP.it,
|
||||
text: t('marketplace.templateCategory.it', { ns: 'plugin' }),
|
||||
icon: null,
|
||||
},
|
||||
{
|
||||
value: TEMPLATE_CATEGORY_MAP.knowledge,
|
||||
text: t('marketplace.templateCategory.knowledge', { ns: 'plugin' }),
|
||||
icon: null,
|
||||
},
|
||||
{
|
||||
value: TEMPLATE_CATEGORY_MAP.design,
|
||||
text: t('marketplace.templateCategory.design', { ns: 'plugin' }),
|
||||
icon: null,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<CommonCategorySwitch
|
||||
className={className}
|
||||
variant={variant}
|
||||
options={options}
|
||||
activeValue={activeTemplateCategory}
|
||||
onChange={handleActiveTemplateCategoryChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
19
web/app/components/plugins/marketplace/constants.spec.ts
Normal file
19
web/app/components/plugins/marketplace/constants.spec.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getValidatedPluginCategory } from './constants'
|
||||
|
||||
describe('getValidatedPluginCategory', () => {
|
||||
it('returns agent-strategy when query value is agent-strategy', () => {
|
||||
expect(getValidatedPluginCategory('agent-strategy')).toBe('agent-strategy')
|
||||
})
|
||||
|
||||
it('returns valid category values unchanged', () => {
|
||||
expect(getValidatedPluginCategory('model')).toBe('model')
|
||||
expect(getValidatedPluginCategory('tool')).toBe('tool')
|
||||
expect(getValidatedPluginCategory('bundle')).toBe('bundle')
|
||||
})
|
||||
|
||||
it('falls back to all for invalid category values', () => {
|
||||
expect(getValidatedPluginCategory('agent')).toBe('all')
|
||||
expect(getValidatedPluginCategory('invalid-category')).toBe('all')
|
||||
})
|
||||
})
|
||||
@@ -7,8 +7,10 @@ export const DEFAULT_SORT = {
|
||||
|
||||
export const SCROLL_BOTTOM_THRESHOLD = 100
|
||||
|
||||
export const CATEGORY_ALL = 'all'
|
||||
|
||||
export const PLUGIN_TYPE_SEARCH_MAP = {
|
||||
all: 'all',
|
||||
all: CATEGORY_ALL,
|
||||
model: PluginCategoryEnum.model,
|
||||
tool: PluginCategoryEnum.tool,
|
||||
agent: PluginCategoryEnum.agent,
|
||||
@@ -21,6 +23,7 @@ export const PLUGIN_TYPE_SEARCH_MAP = {
|
||||
type ValueOf<T> = T[keyof T]
|
||||
|
||||
export type ActivePluginType = ValueOf<typeof PLUGIN_TYPE_SEARCH_MAP>
|
||||
const VALID_PLUGIN_CATEGORIES = new Set<ActivePluginType>(Object.values(PLUGIN_TYPE_SEARCH_MAP))
|
||||
|
||||
export const PLUGIN_CATEGORY_WITH_COLLECTIONS = new Set<ActivePluginType>(
|
||||
[
|
||||
@@ -28,3 +31,28 @@ export const PLUGIN_CATEGORY_WITH_COLLECTIONS = new Set<ActivePluginType>(
|
||||
PLUGIN_TYPE_SEARCH_MAP.tool,
|
||||
],
|
||||
)
|
||||
|
||||
export const TEMPLATE_CATEGORY_MAP = {
|
||||
all: CATEGORY_ALL,
|
||||
marketing: 'marketing',
|
||||
sales: 'sales',
|
||||
support: 'support',
|
||||
operations: 'operations',
|
||||
it: 'it',
|
||||
knowledge: 'knowledge',
|
||||
design: 'design',
|
||||
} as const
|
||||
|
||||
export type ActiveTemplateCategory = typeof TEMPLATE_CATEGORY_MAP[keyof typeof TEMPLATE_CATEGORY_MAP]
|
||||
|
||||
export function getValidatedPluginCategory(category: string): ActivePluginType {
|
||||
if (VALID_PLUGIN_CATEGORIES.has(category as ActivePluginType))
|
||||
return category as ActivePluginType
|
||||
|
||||
return CATEGORY_ALL
|
||||
}
|
||||
|
||||
export function getValidatedTemplateCategory(category: string): ActiveTemplateCategory {
|
||||
const key = (category in TEMPLATE_CATEGORY_MAP ? category : CATEGORY_ALL) as keyof typeof TEMPLATE_CATEGORY_MAP
|
||||
return TEMPLATE_CATEGORY_MAP[key]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Description from './index'
|
||||
import { Description } from './index'
|
||||
|
||||
// ================================
|
||||
// Mock external dependencies
|
||||
|
||||
@@ -1,72 +1,184 @@
|
||||
import { useLocale, useTranslation } from '#i18n'
|
||||
'use client'
|
||||
|
||||
const Description = () => {
|
||||
const { t } = useTranslation('plugin')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const locale = useLocale()
|
||||
import type { MotionValue } from 'motion/react'
|
||||
import { useTranslation } from '#i18n'
|
||||
import { motion, useMotionValue, useSpring, useTransform } from 'motion/react'
|
||||
import { useEffect, useLayoutEffect, useRef } from 'react'
|
||||
import marketPlaceBg from '@/public/marketplace/hero-bg.jpg'
|
||||
import marketplaceGradientNoise from '@/public/marketplace/hero-gradient-noise.svg'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useCreationType } from '../atoms'
|
||||
import { PluginCategorySwitch, TemplateCategorySwitch } from '../category-switch/index'
|
||||
import { CREATION_TYPE } from '../search-params'
|
||||
|
||||
const isZhHans = locale === '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>
|
||||
</>
|
||||
)
|
||||
type DescriptionProps = {
|
||||
className?: string
|
||||
scrollContainerId?: string
|
||||
marketplaceNav?: React.ReactNode
|
||||
}
|
||||
|
||||
export default Description
|
||||
// Constants for collapse animation
|
||||
const MAX_SCROLL = 120 // pixels to fully collapse
|
||||
const EXPANDED_PADDING_TOP = 32 // pt-8
|
||||
const COLLAPSED_PADDING_TOP = 12 // pt-3
|
||||
const EXPANDED_PADDING_BOTTOM = 24 // pb-6
|
||||
const COLLAPSED_PADDING_BOTTOM = 12 // pb-3
|
||||
|
||||
export const Description = ({
|
||||
className,
|
||||
scrollContainerId = 'marketplace-container',
|
||||
marketplaceNav,
|
||||
}: DescriptionProps) => {
|
||||
const { t } = useTranslation('plugin')
|
||||
const [creationType] = useCreationType()
|
||||
const isTemplatesView = creationType === CREATION_TYPE.templates
|
||||
const heroTitleKey = isTemplatesView ? 'marketplace.templatesHeroTitle' : 'marketplace.pluginsHeroTitle'
|
||||
const heroSubtitleKey = isTemplatesView ? 'marketplace.templatesHeroSubtitle' : 'marketplace.pluginsHeroSubtitle'
|
||||
const rafRef = useRef<number | null>(null)
|
||||
const lastProgressRef = useRef(0)
|
||||
const titleRef = useRef<HTMLDivElement | null>(null)
|
||||
const progress = useMotionValue(0)
|
||||
const titleHeight = useMotionValue(0)
|
||||
const smoothProgress = useSpring(progress, { stiffness: 260, damping: 34 })
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const node = titleRef.current
|
||||
if (!node)
|
||||
return
|
||||
|
||||
const updateHeight = () => {
|
||||
titleHeight.set(node.scrollHeight)
|
||||
}
|
||||
|
||||
updateHeight()
|
||||
|
||||
if (typeof ResizeObserver === 'undefined')
|
||||
return
|
||||
|
||||
const observer = new ResizeObserver(updateHeight)
|
||||
observer.observe(node)
|
||||
return () => observer.disconnect()
|
||||
}, [titleHeight])
|
||||
|
||||
useEffect(() => {
|
||||
const container = document.getElementById(scrollContainerId)
|
||||
if (!container)
|
||||
return
|
||||
|
||||
const handleScroll = () => {
|
||||
// Cancel any pending animation frame
|
||||
if (rafRef.current)
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
|
||||
// Use requestAnimationFrame for smooth updates
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
const scrollTop = Math.round(container.scrollTop)
|
||||
const heightDelta = container.scrollHeight - container.clientHeight
|
||||
const effectiveMaxScroll = Math.max(1, Math.min(MAX_SCROLL, heightDelta))
|
||||
const rawProgress = Math.min(Math.max(scrollTop / effectiveMaxScroll, 0), 1)
|
||||
const snappedProgress = rawProgress >= 0.95
|
||||
? 1
|
||||
: rawProgress <= 0.05
|
||||
? 0
|
||||
: Math.round(rawProgress * 100) / 100
|
||||
|
||||
if (snappedProgress !== lastProgressRef.current) {
|
||||
lastProgressRef.current = snappedProgress
|
||||
progress.set(snappedProgress)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
container.addEventListener('scroll', handleScroll, { passive: true })
|
||||
|
||||
// Initial check
|
||||
handleScroll()
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('scroll', handleScroll)
|
||||
if (rafRef.current)
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
}
|
||||
}, [progress, scrollContainerId])
|
||||
|
||||
// Calculate interpolated values
|
||||
const contentOpacity = useTransform(smoothProgress, [0, 1], [1, 0])
|
||||
const contentScale = useTransform(smoothProgress, [0, 1], [1, 0.9])
|
||||
const titleMaxHeight: MotionValue<number> = useTransform(
|
||||
[smoothProgress, titleHeight],
|
||||
(values: number[]) => values[1] * (1 - values[0]),
|
||||
)
|
||||
const tabsMarginTop = useTransform(smoothProgress, [0, 1], [48, marketplaceNav ? 16 : 0])
|
||||
const titleMarginTop = useTransform(smoothProgress, [0, 1], [marketplaceNav ? 80 : 0, 0])
|
||||
const paddingTop = useTransform(smoothProgress, [0, 1], [marketplaceNav ? COLLAPSED_PADDING_TOP : EXPANDED_PADDING_TOP, COLLAPSED_PADDING_TOP])
|
||||
const paddingBottom = useTransform(smoothProgress, [0, 1], [EXPANDED_PADDING_BOTTOM, COLLAPSED_PADDING_BOTTOM])
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={cn(
|
||||
'sticky top-[60px] z-20 mx-4 mt-4 shrink-0 overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border px-6',
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
paddingTop,
|
||||
paddingBottom,
|
||||
}}
|
||||
>
|
||||
{/* Blue base background */}
|
||||
<div className="absolute inset-0 bg-[rgba(0,51,255,0.9)]" />
|
||||
|
||||
{/* Decorative image with blend mode - showing top 1/3 of the image */}
|
||||
<div
|
||||
className="absolute inset-0 bg-no-repeat opacity-80 mix-blend-lighten"
|
||||
style={{
|
||||
backgroundImage: `url(${marketPlaceBg.src})`,
|
||||
backgroundSize: '110% auto',
|
||||
backgroundPosition: 'center top',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Gradient & Noise overlay */}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||
style={{ backgroundImage: `url(${marketplaceGradientNoise.src})` }}
|
||||
/>
|
||||
|
||||
{marketplaceNav}
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10">
|
||||
{/* Title and subtitle - fade out and scale down */}
|
||||
<motion.div
|
||||
ref={titleRef}
|
||||
style={{
|
||||
opacity: contentOpacity,
|
||||
scale: contentScale,
|
||||
transformOrigin: 'left top',
|
||||
maxHeight: titleMaxHeight,
|
||||
overflow: 'hidden',
|
||||
willChange: 'opacity, transform',
|
||||
marginTop: titleMarginTop,
|
||||
}}
|
||||
>
|
||||
<h1 className="title-4xl-semi-bold mb-2 shrink-0 text-text-primary-on-surface">
|
||||
{t(heroTitleKey)}
|
||||
</h1>
|
||||
<h2 className="body-md-regular shrink-0 text-text-secondary-on-surface">
|
||||
{t(heroSubtitleKey)}
|
||||
</h2>
|
||||
</motion.div>
|
||||
|
||||
{/* Category switch tabs - Plugin or Template based on creationType */}
|
||||
<motion.div style={{ marginTop: tabsMarginTop }}>
|
||||
{isTemplatesView
|
||||
? (
|
||||
<TemplateCategorySwitch variant="hero" />
|
||||
)
|
||||
: (
|
||||
<PluginCategorySwitch variant="hero" />
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type {
|
||||
} from '../types'
|
||||
import type {
|
||||
CollectionsAndPluginsSearchParams,
|
||||
MarketplaceCollection,
|
||||
PluginCollection,
|
||||
PluginsSearchParams,
|
||||
} from './types'
|
||||
import type { PluginsFromMarketplaceResponse } from '@/app/components/plugins/types'
|
||||
@@ -31,8 +31,8 @@ import {
|
||||
*/
|
||||
export const useMarketplaceCollectionsAndPlugins = () => {
|
||||
const [queryParams, setQueryParams] = useState<CollectionsAndPluginsSearchParams>()
|
||||
const [marketplaceCollectionsOverride, setMarketplaceCollections] = useState<MarketplaceCollection[]>()
|
||||
const [marketplaceCollectionPluginsMapOverride, setMarketplaceCollectionPluginsMap] = useState<Record<string, Plugin[]>>()
|
||||
const [pluginCollectionsOverride, setPluginCollections] = useState<PluginCollection[]>()
|
||||
const [pluginCollectionPluginsMapOverride, setPluginCollectionPluginsMap] = useState<Record<string, Plugin[]>>()
|
||||
|
||||
const {
|
||||
data,
|
||||
@@ -54,10 +54,10 @@ export const useMarketplaceCollectionsAndPlugins = () => {
|
||||
const isLoading = !!queryParams && (isFetching || isPending)
|
||||
|
||||
return {
|
||||
marketplaceCollections: marketplaceCollectionsOverride ?? data?.marketplaceCollections,
|
||||
setMarketplaceCollections,
|
||||
marketplaceCollectionPluginsMap: marketplaceCollectionPluginsMapOverride ?? data?.marketplaceCollectionPluginsMap,
|
||||
setMarketplaceCollectionPluginsMap,
|
||||
pluginCollections: pluginCollectionsOverride ?? data?.marketplaceCollections,
|
||||
setPluginCollections,
|
||||
pluginCollectionPluginsMap: pluginCollectionPluginsMapOverride ?? data?.marketplaceCollectionPluginsMap,
|
||||
setPluginCollectionPluginsMap,
|
||||
queryMarketplaceCollectionsAndPlugins,
|
||||
isLoading,
|
||||
isSuccess,
|
||||
|
||||
@@ -3,9 +3,9 @@ import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
|
||||
import { createLoader } from 'nuqs/server'
|
||||
import { getQueryClientServer } from '@/context/query-client-server'
|
||||
import { marketplaceQuery } from '@/service/client'
|
||||
import { PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants'
|
||||
import { marketplaceSearchParamsParsers } from './search-params'
|
||||
import { getCollectionsParams, getMarketplaceCollectionsAndPlugins } from './utils'
|
||||
import { getValidatedPluginCategory, PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants'
|
||||
import { CREATION_TYPE, marketplaceSearchParamsParsers } from './search-params'
|
||||
import { getCollectionsParams, getMarketplaceCollectionsAndPlugins, getMarketplaceTemplateCollectionsAndTemplates } from './utils'
|
||||
|
||||
// The server side logic should move to marketplace's codebase so that we can get rid of Next.js
|
||||
|
||||
@@ -15,16 +15,26 @@ async function getDehydratedState(searchParams?: Promise<SearchParams>) {
|
||||
}
|
||||
const loadSearchParams = createLoader(marketplaceSearchParamsParsers)
|
||||
const params = await loadSearchParams(searchParams)
|
||||
const queryClient = getQueryClientServer()
|
||||
|
||||
if (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(params.category)) {
|
||||
if (params.creationType === CREATION_TYPE.templates) {
|
||||
await queryClient.prefetchQuery({
|
||||
queryKey: marketplaceQuery.templateCollections.list.queryKey({ input: { query: undefined } }),
|
||||
queryFn: () => getMarketplaceTemplateCollectionsAndTemplates(),
|
||||
})
|
||||
return dehydrate(queryClient)
|
||||
}
|
||||
|
||||
const pluginCategory = getValidatedPluginCategory(params.category)
|
||||
|
||||
if (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(pluginCategory)) {
|
||||
return
|
||||
}
|
||||
|
||||
const queryClient = getQueryClientServer()
|
||||
|
||||
const collectionsParams = getCollectionsParams(pluginCategory)
|
||||
await queryClient.prefetchQuery({
|
||||
queryKey: marketplaceQuery.collections.queryKey({ input: { query: getCollectionsParams(params.category) } }),
|
||||
queryFn: () => getMarketplaceCollectionsAndPlugins(getCollectionsParams(params.category)),
|
||||
queryKey: marketplaceQuery.plugins.collections.queryKey({ input: { query: collectionsParams } }),
|
||||
queryFn: () => getMarketplaceCollectionsAndPlugins(collectionsParams),
|
||||
})
|
||||
return dehydrate(queryClient)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MarketplaceCollection } from './types'
|
||||
import type { PluginCollection } from './types'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { act, render, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -12,9 +12,9 @@ import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import { DEFAULT_SORT, PLUGIN_TYPE_SEARCH_MAP, SCROLL_BOTTOM_THRESHOLD } from './constants'
|
||||
import {
|
||||
getFormattedPlugin,
|
||||
getMarketplaceListCondition,
|
||||
getMarketplaceListFilterType,
|
||||
getPluginCondition,
|
||||
getPluginDetailLinkInMarketplace,
|
||||
getPluginFilterType,
|
||||
getPluginIconInMarketplace,
|
||||
getPluginLinkInMarketplace,
|
||||
} from './utils'
|
||||
@@ -322,11 +322,10 @@ vi.mock('@/app/components/plugins/card', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock CardMoreInfo component
|
||||
vi.mock('@/app/components/plugins/card/card-more-info', () => ({
|
||||
default: ({ downloadCount, tags }: { downloadCount: number, tags: string[] }) => (
|
||||
<div data-testid="card-more-info">
|
||||
<span data-testid="download-count">{downloadCount}</span>
|
||||
// Mock CardTags component
|
||||
vi.mock('@/app/components/plugins/card/card-tags', () => ({
|
||||
default: ({ tags }: { tags: string[] }) => (
|
||||
<div data-testid="card-tags">
|
||||
<span data-testid="tags">{tags.join(',')}</span>
|
||||
</div>
|
||||
),
|
||||
@@ -387,7 +386,7 @@ const createMockPluginList = (count: number): Plugin[] =>
|
||||
install_count: 1000 - i * 10,
|
||||
}))
|
||||
|
||||
const createMockCollection = (overrides?: Partial<MarketplaceCollection>): MarketplaceCollection => ({
|
||||
const createMockCollection = (overrides?: Partial<PluginCollection>): PluginCollection => ({
|
||||
name: 'test-collection',
|
||||
label: { 'en-US': 'Test Collection' },
|
||||
description: { 'en-US': 'Test collection description' },
|
||||
@@ -541,57 +540,57 @@ describe('utils', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMarketplaceListCondition', () => {
|
||||
describe('getPluginCondition', () => {
|
||||
it('should return category condition for tool', () => {
|
||||
expect(getMarketplaceListCondition(PluginCategoryEnum.tool)).toBe('category=tool')
|
||||
expect(getPluginCondition(PluginCategoryEnum.tool)).toBe('category=tool')
|
||||
})
|
||||
|
||||
it('should return category condition for model', () => {
|
||||
expect(getMarketplaceListCondition(PluginCategoryEnum.model)).toBe('category=model')
|
||||
expect(getPluginCondition(PluginCategoryEnum.model)).toBe('category=model')
|
||||
})
|
||||
|
||||
it('should return category condition for agent', () => {
|
||||
expect(getMarketplaceListCondition(PluginCategoryEnum.agent)).toBe('category=agent-strategy')
|
||||
expect(getPluginCondition(PluginCategoryEnum.agent)).toBe('category=agent-strategy')
|
||||
})
|
||||
|
||||
it('should return category condition for datasource', () => {
|
||||
expect(getMarketplaceListCondition(PluginCategoryEnum.datasource)).toBe('category=datasource')
|
||||
expect(getPluginCondition(PluginCategoryEnum.datasource)).toBe('category=datasource')
|
||||
})
|
||||
|
||||
it('should return category condition for trigger', () => {
|
||||
expect(getMarketplaceListCondition(PluginCategoryEnum.trigger)).toBe('category=trigger')
|
||||
expect(getPluginCondition(PluginCategoryEnum.trigger)).toBe('category=trigger')
|
||||
})
|
||||
|
||||
it('should return endpoint category for extension', () => {
|
||||
expect(getMarketplaceListCondition(PluginCategoryEnum.extension)).toBe('category=endpoint')
|
||||
expect(getPluginCondition(PluginCategoryEnum.extension)).toBe('category=endpoint')
|
||||
})
|
||||
|
||||
it('should return type condition for bundle', () => {
|
||||
expect(getMarketplaceListCondition('bundle')).toBe('type=bundle')
|
||||
expect(getPluginCondition('bundle')).toBe('type=bundle')
|
||||
})
|
||||
|
||||
it('should return empty string for all', () => {
|
||||
expect(getMarketplaceListCondition('all')).toBe('')
|
||||
expect(getPluginCondition('all')).toBe('')
|
||||
})
|
||||
|
||||
it('should return empty string for unknown type', () => {
|
||||
expect(getMarketplaceListCondition('unknown')).toBe('')
|
||||
expect(getPluginCondition('unknown')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMarketplaceListFilterType', () => {
|
||||
describe('getPluginFilterType', () => {
|
||||
it('should return undefined for all', () => {
|
||||
expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.all)).toBeUndefined()
|
||||
expect(getPluginFilterType(PLUGIN_TYPE_SEARCH_MAP.all)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return bundle for bundle', () => {
|
||||
expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.bundle)).toBe('bundle')
|
||||
expect(getPluginFilterType(PLUGIN_TYPE_SEARCH_MAP.bundle)).toBe('bundle')
|
||||
})
|
||||
|
||||
it('should return plugin for other categories', () => {
|
||||
expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.tool)).toBe('plugin')
|
||||
expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.model)).toBe('plugin')
|
||||
expect(getMarketplaceListFilterType(PLUGIN_TYPE_SEARCH_MAP.agent)).toBe('plugin')
|
||||
expect(getPluginFilterType(PLUGIN_TYPE_SEARCH_MAP.tool)).toBe('plugin')
|
||||
expect(getPluginFilterType(PLUGIN_TYPE_SEARCH_MAP.model)).toBe('plugin')
|
||||
expect(getPluginFilterType(PLUGIN_TYPE_SEARCH_MAP.agent)).toBe('plugin')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -611,8 +610,8 @@ describe('useMarketplaceCollectionsAndPlugins', () => {
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
expect(result.current.isSuccess).toBe(false)
|
||||
expect(result.current.queryMarketplaceCollectionsAndPlugins).toBeDefined()
|
||||
expect(result.current.setMarketplaceCollections).toBeDefined()
|
||||
expect(result.current.setMarketplaceCollectionPluginsMap).toBeDefined()
|
||||
expect(result.current.setPluginCollections).toBeDefined()
|
||||
expect(result.current.setPluginCollectionPluginsMap).toBeDefined()
|
||||
})
|
||||
|
||||
it('should provide queryMarketplaceCollectionsAndPlugins function', async () => {
|
||||
@@ -622,34 +621,34 @@ describe('useMarketplaceCollectionsAndPlugins', () => {
|
||||
expect(typeof result.current.queryMarketplaceCollectionsAndPlugins).toBe('function')
|
||||
})
|
||||
|
||||
it('should provide setMarketplaceCollections function', async () => {
|
||||
it('should provide setPluginCollections function', async () => {
|
||||
const { useMarketplaceCollectionsAndPlugins } = await import('./hooks')
|
||||
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
|
||||
|
||||
expect(typeof result.current.setMarketplaceCollections).toBe('function')
|
||||
expect(typeof result.current.setPluginCollections).toBe('function')
|
||||
})
|
||||
|
||||
it('should provide setMarketplaceCollectionPluginsMap function', async () => {
|
||||
it('should provide setPluginCollectionPluginsMap function', async () => {
|
||||
const { useMarketplaceCollectionsAndPlugins } = await import('./hooks')
|
||||
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
|
||||
|
||||
expect(typeof result.current.setMarketplaceCollectionPluginsMap).toBe('function')
|
||||
expect(typeof result.current.setPluginCollectionPluginsMap).toBe('function')
|
||||
})
|
||||
|
||||
it('should return marketplaceCollections from data or override', async () => {
|
||||
it('should return pluginCollections from data or override', async () => {
|
||||
const { useMarketplaceCollectionsAndPlugins } = await import('./hooks')
|
||||
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
|
||||
|
||||
// Initial state
|
||||
expect(result.current.marketplaceCollections).toBeUndefined()
|
||||
expect(result.current.pluginCollections).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return marketplaceCollectionPluginsMap from data or override', async () => {
|
||||
it('should return pluginCollectionPluginsMap from data or override', async () => {
|
||||
const { useMarketplaceCollectionsAndPlugins } = await import('./hooks')
|
||||
const { result } = renderHook(() => useMarketplaceCollectionsAndPlugins())
|
||||
|
||||
// Initial state
|
||||
expect(result.current.marketplaceCollectionPluginsMap).toBeUndefined()
|
||||
expect(result.current.pluginCollectionPluginsMap).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,32 +1,34 @@
|
||||
import type { SearchParams } from 'nuqs'
|
||||
import { TanstackQueryInitializer } from '@/context/query-client'
|
||||
import Description from './description'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { HydrateQueryClient } from './hydration-server'
|
||||
import ListWrapper from './list/list-wrapper'
|
||||
import StickySearchAndSwitchWrapper from './sticky-search-and-switch-wrapper'
|
||||
import MarketplaceContent from './marketplace-content'
|
||||
import MarketplaceHeader from './marketplace-header'
|
||||
|
||||
type MarketplaceProps = {
|
||||
showInstallButton?: boolean
|
||||
pluginTypeSwitchClassName?: string
|
||||
/**
|
||||
* Pass the search params from the request to prefetch data on the server.
|
||||
*/
|
||||
searchParams?: Promise<SearchParams>
|
||||
/**
|
||||
* Whether the marketplace is the platform marketplace.
|
||||
*/
|
||||
isMarketplacePlatform?: boolean
|
||||
marketplaceNav?: React.ReactNode
|
||||
}
|
||||
|
||||
const Marketplace = async ({
|
||||
showInstallButton = true,
|
||||
pluginTypeSwitchClassName,
|
||||
searchParams,
|
||||
isMarketplacePlatform = false,
|
||||
marketplaceNav,
|
||||
}: MarketplaceProps) => {
|
||||
return (
|
||||
<TanstackQueryInitializer>
|
||||
<HydrateQueryClient searchParams={searchParams}>
|
||||
<Description />
|
||||
<StickySearchAndSwitchWrapper
|
||||
pluginTypeSwitchClassName={pluginTypeSwitchClassName}
|
||||
/>
|
||||
<ListWrapper
|
||||
<MarketplaceHeader descriptionClassName={cn('mx-12 mt-1', isMarketplacePlatform && 'top-0 mx-0 mt-0 rounded-none')} marketplaceNav={marketplaceNav} />
|
||||
<MarketplaceContent
|
||||
showInstallButton={showInstallButton}
|
||||
/>
|
||||
</HydrateQueryClient>
|
||||
|
||||
@@ -8,7 +8,7 @@ import * as React from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Card from '@/app/components/plugins/card'
|
||||
import CardMoreInfo from '@/app/components/plugins/card/card-more-info'
|
||||
import CardTags from '@/app/components/plugins/card/card-tags'
|
||||
import { useTags } from '@/app/components/plugins/hooks'
|
||||
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
|
||||
import { getPluginDetailLinkInMarketplace, getPluginLinkInMarketplace } from '../utils'
|
||||
@@ -43,14 +43,13 @@ const CardWrapperComponent = ({
|
||||
if (showInstallButton) {
|
||||
return (
|
||||
<div
|
||||
className="group relative cursor-pointer rounded-xl hover:bg-components-panel-on-panel-item-bg-hover"
|
||||
className="group relative cursor-pointer rounded-xl hover:bg-components-panel-on-panel-item-bg-hover"
|
||||
>
|
||||
<Card
|
||||
key={plugin.name}
|
||||
payload={plugin}
|
||||
footer={(
|
||||
<CardMoreInfo
|
||||
downloadCount={plugin.install_count}
|
||||
<CardTags
|
||||
tags={tagLabels}
|
||||
/>
|
||||
)}
|
||||
@@ -88,15 +87,15 @@ const CardWrapperComponent = ({
|
||||
|
||||
return (
|
||||
<a
|
||||
className="group relative inline-block cursor-pointer rounded-xl"
|
||||
className="group relative block cursor-pointer rounded-xl"
|
||||
href={getPluginDetailLinkInMarketplace(plugin)}
|
||||
>
|
||||
<Card
|
||||
key={plugin.name}
|
||||
payload={plugin}
|
||||
disableOrgLink
|
||||
footer={(
|
||||
<CardMoreInfo
|
||||
downloadCount={plugin.install_count}
|
||||
<CardTags
|
||||
tags={tagLabels}
|
||||
/>
|
||||
)}
|
||||
|
||||
255
web/app/components/plugins/marketplace/list/carousel.tsx
Normal file
255
web/app/components/plugins/marketplace/list/carousel.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
'use client'
|
||||
|
||||
import type { RemixiconComponentType } from '@remixicon/react'
|
||||
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
|
||||
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type CarouselProps = {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
itemWidth?: number
|
||||
gap?: number
|
||||
showNavigation?: boolean
|
||||
showPagination?: boolean
|
||||
autoPlay?: boolean
|
||||
autoPlayInterval?: number
|
||||
}
|
||||
|
||||
type ScrollState = {
|
||||
canScrollLeft: boolean
|
||||
canScrollRight: boolean
|
||||
currentPage: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
const SCROLL_OVERLAP_RATIO = 0.5
|
||||
|
||||
const defaultScrollState: ScrollState = {
|
||||
canScrollLeft: false,
|
||||
canScrollRight: false,
|
||||
currentPage: 0,
|
||||
totalPages: 0,
|
||||
}
|
||||
|
||||
type NavButtonProps = {
|
||||
direction: 'left' | 'right'
|
||||
disabled: boolean
|
||||
onClick: () => void
|
||||
Icon: RemixiconComponentType
|
||||
}
|
||||
|
||||
const NavButton = ({ direction, disabled, onClick, Icon }: NavButtonProps) => (
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center justify-center rounded-full border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg p-2 shadow-xs backdrop-blur-[5px] transition-all',
|
||||
disabled
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
: 'cursor-pointer hover:bg-components-button-secondary-bg-hover',
|
||||
)}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={`Scroll ${direction}`}
|
||||
>
|
||||
<Icon className="h-4 w-4 text-components-button-secondary-text" />
|
||||
</button>
|
||||
)
|
||||
|
||||
const Carousel = ({
|
||||
children,
|
||||
className,
|
||||
itemWidth = 280,
|
||||
gap = 12,
|
||||
showNavigation = true,
|
||||
showPagination = true,
|
||||
autoPlay = false,
|
||||
autoPlayInterval = 5000,
|
||||
}: CarouselProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const scrollStateRef = useRef<ScrollState>(defaultScrollState)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
const calculateScrollState = useCallback((container: HTMLDivElement): ScrollState => {
|
||||
const { scrollLeft, scrollWidth, clientWidth } = container
|
||||
const canScrollLeft = scrollLeft > 0
|
||||
const canScrollRight = scrollLeft < scrollWidth - clientWidth - 1
|
||||
|
||||
// Calculate total pages based on actual scroll range
|
||||
const maxScrollLeft = scrollWidth - clientWidth
|
||||
const itemsPerPage = Math.floor(clientWidth / (itemWidth + gap))
|
||||
const totalItems = container.children.length
|
||||
const pages = Math.max(1, Math.ceil(totalItems / itemsPerPage))
|
||||
|
||||
// Calculate current page based on scroll position ratio
|
||||
let currentPage = 0
|
||||
if (maxScrollLeft > 0) {
|
||||
const scrollRatio = scrollLeft / maxScrollLeft
|
||||
currentPage = Math.round(scrollRatio * (pages - 1))
|
||||
}
|
||||
|
||||
return {
|
||||
canScrollLeft,
|
||||
canScrollRight,
|
||||
totalPages: pages,
|
||||
currentPage: Math.min(Math.max(0, currentPage), pages - 1),
|
||||
}
|
||||
}, [itemWidth, gap])
|
||||
|
||||
const subscribe = useCallback((onStoreChange: () => void) => {
|
||||
const container = containerRef.current
|
||||
if (!container)
|
||||
return () => { }
|
||||
|
||||
const handleChange = () => {
|
||||
scrollStateRef.current = calculateScrollState(container)
|
||||
onStoreChange()
|
||||
}
|
||||
|
||||
// Initial calculation
|
||||
handleChange()
|
||||
|
||||
const resizeObserver = new ResizeObserver(handleChange)
|
||||
resizeObserver.observe(container)
|
||||
container.addEventListener('scroll', handleChange)
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
container.removeEventListener('scroll', handleChange)
|
||||
}
|
||||
}, [calculateScrollState])
|
||||
|
||||
const getSnapshot = useCallback(() => scrollStateRef.current, [])
|
||||
|
||||
const scrollState = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
||||
|
||||
// Re-subscribe when children change
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (container)
|
||||
scrollStateRef.current = calculateScrollState(container)
|
||||
}, [children, calculateScrollState])
|
||||
|
||||
const scrollToPage = useCallback((pageIndex: number, instant = false) => {
|
||||
const container = containerRef.current
|
||||
if (!container)
|
||||
return
|
||||
|
||||
const itemsPerPage = Math.floor(container.clientWidth / (itemWidth + gap))
|
||||
const scrollLeft = pageIndex * itemsPerPage * (itemWidth + gap)
|
||||
|
||||
container.scrollTo({
|
||||
left: scrollLeft,
|
||||
behavior: instant ? 'instant' : 'smooth',
|
||||
})
|
||||
}, [itemWidth, gap])
|
||||
|
||||
const scroll = useCallback((direction: 'left' | 'right') => {
|
||||
const container = containerRef.current
|
||||
if (!container)
|
||||
return
|
||||
|
||||
// Handle looping
|
||||
if (direction === 'left' && !scrollState.canScrollLeft) {
|
||||
// At first page, loop to last page
|
||||
scrollToPage(scrollState.totalPages - 1, true)
|
||||
return
|
||||
}
|
||||
if (direction === 'right' && !scrollState.canScrollRight) {
|
||||
// At last page, loop to first page
|
||||
scrollToPage(0, true)
|
||||
return
|
||||
}
|
||||
|
||||
const scrollAmount = container.clientWidth - (itemWidth * SCROLL_OVERLAP_RATIO)
|
||||
const newScrollLeft = direction === 'left'
|
||||
? container.scrollLeft - scrollAmount
|
||||
: container.scrollLeft + scrollAmount
|
||||
|
||||
container.scrollTo({
|
||||
left: newScrollLeft,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}, [itemWidth, scrollState.canScrollLeft, scrollState.canScrollRight, scrollState.totalPages, scrollToPage])
|
||||
|
||||
// Auto-play functionality
|
||||
useEffect(() => {
|
||||
if (!autoPlay || isHovered || scrollState.totalPages <= 1)
|
||||
return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (scrollState.canScrollRight) {
|
||||
scrollToPage(scrollState.currentPage + 1)
|
||||
}
|
||||
else {
|
||||
// Loop back to first page instantly (no animation)
|
||||
scrollToPage(0, true)
|
||||
}
|
||||
}, autoPlayInterval)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [autoPlay, autoPlayInterval, isHovered, scrollState.totalPages, scrollState.canScrollRight, scrollState.currentPage, scrollToPage])
|
||||
|
||||
const handleMouseEnter = useCallback(() => setIsHovered(true), [])
|
||||
const handleMouseLeave = useCallback(() => setIsHovered(false), [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('relative', className)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{/* Navigation arrows */}
|
||||
{showNavigation && (
|
||||
<div className="absolute -top-10 right-0 flex items-center gap-3">
|
||||
{/* Pagination dots */}
|
||||
{showPagination && scrollState.totalPages > 1 && (
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: scrollState.totalPages }).map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className={cn(
|
||||
'h-[5px] w-[5px] rounded-full transition-all',
|
||||
scrollState.currentPage === index
|
||||
? 'w-4 bg-components-button-primary-bg'
|
||||
: 'bg-components-button-secondary-border hover:bg-components-button-secondary-border-hover',
|
||||
)}
|
||||
onClick={() => scrollToPage(index)}
|
||||
aria-label={`Go to page ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<NavButton
|
||||
direction="left"
|
||||
disabled={scrollState.totalPages <= 1}
|
||||
onClick={() => scroll('left')}
|
||||
Icon={RiArrowLeftSLine}
|
||||
/>
|
||||
<NavButton
|
||||
direction="right"
|
||||
disabled={scrollState.totalPages <= 1}
|
||||
onClick={() => scroll('right')}
|
||||
Icon={RiArrowRightSLine}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scrollable container */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="no-scrollbar flex gap-3 overflow-x-auto scroll-smooth"
|
||||
style={{
|
||||
scrollSnapType: 'x mandatory',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Carousel
|
||||
213
web/app/components/plugins/marketplace/list/collection-list.tsx
Normal file
213
web/app/components/plugins/marketplace/list/collection-list.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
'use client'
|
||||
|
||||
import type { SearchTab } from '../search-params'
|
||||
import type { SearchParamsFromCollection } from '../types'
|
||||
import type { Locale } from '@/i18n-config/language'
|
||||
import { useLocale, useTranslation } from '#i18n'
|
||||
import { RiArrowRightSLine } from '@remixicon/react'
|
||||
import { getLanguage } from '@/i18n-config/language'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useMarketplaceMoreClick } from '../atoms'
|
||||
import { getItemKeyByField } from '../utils'
|
||||
import Empty from '../empty'
|
||||
import Carousel from './carousel'
|
||||
|
||||
export const GRID_CLASS = 'grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'
|
||||
|
||||
export const GRID_DISPLAY_LIMIT = 8
|
||||
|
||||
export const CAROUSEL_COLUMN_CLASS = 'flex w-[calc((100%-0px)/1)] shrink-0 flex-col gap-3 sm:w-[calc((100%-12px)/2)] lg:w-[calc((100%-24px)/3)] xl:w-[calc((100%-36px)/4)]'
|
||||
|
||||
/** Collection name key that triggers carousel display (plugins: partners, templates: featured) */
|
||||
export const CAROUSEL_COLLECTION_NAMES = {
|
||||
partners: 'partners',
|
||||
featured: 'featured',
|
||||
} as const
|
||||
|
||||
export type BaseCollection = {
|
||||
name: string
|
||||
label: Record<string, string>
|
||||
description: Record<string, string>
|
||||
searchable?: boolean
|
||||
search_params?: { query?: string, sort_by?: string, sort_order?: string }
|
||||
}
|
||||
|
||||
type ViewMoreButtonProps = {
|
||||
searchParams?: SearchParamsFromCollection
|
||||
searchTab?: SearchTab
|
||||
}
|
||||
|
||||
function ViewMoreButton({ searchParams, searchTab }: ViewMoreButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
const onMoreClick = useMarketplaceMoreClick()
|
||||
|
||||
return (
|
||||
<div
|
||||
className="system-xs-medium flex cursor-pointer items-center text-text-accent"
|
||||
onClick={() => onMoreClick(searchParams, searchTab)}
|
||||
>
|
||||
{t('marketplace.viewMore', { ns: 'plugin' })}
|
||||
<RiArrowRightSLine className="h-4 w-4" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { ViewMoreButton }
|
||||
|
||||
type CollectionHeaderProps<TCollection extends BaseCollection> = {
|
||||
collection: TCollection
|
||||
itemsLength: number
|
||||
locale: Locale
|
||||
carouselCollectionNames: string[]
|
||||
viewMore: React.ReactNode
|
||||
}
|
||||
|
||||
function CollectionHeader<TCollection extends BaseCollection>({
|
||||
collection,
|
||||
itemsLength,
|
||||
locale,
|
||||
carouselCollectionNames,
|
||||
viewMore,
|
||||
}: CollectionHeaderProps<TCollection>) {
|
||||
const showViewMore = collection.searchable
|
||||
&& (carouselCollectionNames.includes(collection.name) || itemsLength > GRID_DISPLAY_LIMIT)
|
||||
|
||||
return (
|
||||
<div className="mb-2 flex items-end justify-between">
|
||||
<div>
|
||||
<div className="title-xl-semi-bold text-text-primary">
|
||||
{collection.label[getLanguage(locale)]}
|
||||
</div>
|
||||
<div className="system-xs-regular text-text-tertiary">
|
||||
{collection.description[getLanguage(locale)]}
|
||||
</div>
|
||||
</div>
|
||||
{showViewMore && viewMore}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { CarouselCollection, CollectionHeader }
|
||||
|
||||
type CarouselCollectionProps<TItem> = {
|
||||
items: TItem[]
|
||||
getItemKey: (item: TItem) => string
|
||||
renderCard: (item: TItem) => React.ReactNode
|
||||
cardContainerClassName?: string
|
||||
}
|
||||
|
||||
function CarouselCollection<TItem>({
|
||||
items,
|
||||
getItemKey,
|
||||
renderCard,
|
||||
cardContainerClassName,
|
||||
}: CarouselCollectionProps<TItem>) {
|
||||
const rows: TItem[][] = []
|
||||
for (let i = 0; i < items.length; i += 2)
|
||||
rows.push(items.slice(i, i + 2))
|
||||
|
||||
return (
|
||||
<Carousel
|
||||
className={cardContainerClassName}
|
||||
showNavigation={items.length > 8}
|
||||
showPagination={items.length > 8}
|
||||
autoPlay={items.length > 8}
|
||||
autoPlayInterval={5000}
|
||||
>
|
||||
{rows.map((columnItems, idx) => (
|
||||
<div
|
||||
key={columnItems[0] ? getItemKey(columnItems[0]) : idx}
|
||||
className={CAROUSEL_COLUMN_CLASS}
|
||||
style={{ scrollSnapAlign: 'start' }}
|
||||
>
|
||||
{columnItems.map(item => (
|
||||
<div key={getItemKey(item)}>{renderCard(item)}</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</Carousel>
|
||||
)
|
||||
}
|
||||
|
||||
type CollectionListProps<TItem, TCollection extends BaseCollection> = {
|
||||
collections: TCollection[]
|
||||
collectionItemsMap: Record<string, TItem[]>
|
||||
/** Field name to use as item key (e.g. 'plugin_id', 'template_id'). */
|
||||
itemKeyField: keyof TItem
|
||||
renderCard: (item: TItem) => React.ReactNode
|
||||
/** Collection names that use carousel layout (e.g. ['partners'], ['featured']). */
|
||||
carouselCollectionNames: string[]
|
||||
/** Search tab for ViewMoreButton (e.g. 'templates' for template collections). */
|
||||
viewMoreSearchTab?: SearchTab
|
||||
gridClassName?: string
|
||||
cardContainerClassName?: string
|
||||
emptyClassName?: string
|
||||
}
|
||||
|
||||
function CollectionList<TItem, TCollection extends BaseCollection>({
|
||||
collections,
|
||||
collectionItemsMap,
|
||||
itemKeyField,
|
||||
renderCard,
|
||||
carouselCollectionNames,
|
||||
viewMoreSearchTab,
|
||||
gridClassName = GRID_CLASS,
|
||||
cardContainerClassName,
|
||||
emptyClassName,
|
||||
}: CollectionListProps<TItem, TCollection>) {
|
||||
const locale = useLocale()
|
||||
|
||||
const collectionsWithItems = collections.filter((collection) => {
|
||||
return collectionItemsMap[collection.name]?.length
|
||||
})
|
||||
|
||||
if (collectionsWithItems.length === 0) {
|
||||
return <Empty className={emptyClassName} />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{
|
||||
collectionsWithItems.map((collection) => {
|
||||
const items = collectionItemsMap[collection.name]
|
||||
const isCarouselCollection = carouselCollectionNames.includes(collection.name)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={collection.name}
|
||||
className="py-3"
|
||||
>
|
||||
<CollectionHeader
|
||||
collection={collection}
|
||||
itemsLength={items.length}
|
||||
locale={locale}
|
||||
carouselCollectionNames={carouselCollectionNames}
|
||||
viewMore={<ViewMoreButton searchParams={collection.search_params} searchTab={viewMoreSearchTab} />}
|
||||
/>
|
||||
{isCarouselCollection
|
||||
? (
|
||||
<CarouselCollection
|
||||
items={items}
|
||||
getItemKey={(item) => getItemKeyByField(item, itemKeyField)}
|
||||
renderCard={renderCard}
|
||||
cardContainerClassName={cardContainerClassName}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<div className={cn(gridClassName, cardContainerClassName)}>
|
||||
{items.slice(0, GRID_DISPLAY_LIMIT).map(item => (
|
||||
<div key={getItemKeyByField(item, itemKeyField)}>
|
||||
{renderCard(item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default CollectionList
|
||||
55
web/app/components/plugins/marketplace/list/flat-list.tsx
Normal file
55
web/app/components/plugins/marketplace/list/flat-list.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
|
||||
import type { Template } from '../types'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import Empty from '../empty'
|
||||
import CardWrapper from './card-wrapper'
|
||||
import { GRID_CLASS } from './collection-list'
|
||||
import TemplateCard from './template-card'
|
||||
|
||||
type PluginsVariant = {
|
||||
variant: 'plugins'
|
||||
items: Plugin[]
|
||||
showInstallButton?: boolean
|
||||
}
|
||||
|
||||
type TemplatesVariant = {
|
||||
variant: 'templates'
|
||||
items: Template[]
|
||||
}
|
||||
|
||||
type FlatListProps = PluginsVariant | TemplatesVariant
|
||||
|
||||
const FlatList = (props: FlatListProps) => {
|
||||
if (!props.items.length)
|
||||
return <Empty />
|
||||
|
||||
if (props.variant === 'plugins') {
|
||||
const { items, showInstallButton } = props
|
||||
return (
|
||||
<div className={GRID_CLASS}>
|
||||
{items.map(plugin => (
|
||||
<CardWrapper
|
||||
key={`${plugin.org}/${plugin.name}`}
|
||||
plugin={plugin}
|
||||
showInstallButton={showInstallButton}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { items } = props
|
||||
return (
|
||||
<div className={GRID_CLASS}>
|
||||
{items.map(template => (
|
||||
<TemplateCard
|
||||
key={template.template_id}
|
||||
template={template}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FlatList
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MarketplaceCollection, SearchParamsFromCollection } from '../types'
|
||||
import type { PluginCollection, SearchParamsFromCollection } from '../types'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -36,8 +36,8 @@ const { mockMarketplaceData, mockMoreClick } = vi.hoisted(() => {
|
||||
mockMarketplaceData: {
|
||||
plugins: undefined as Plugin[] | undefined,
|
||||
pluginsTotal: 0,
|
||||
marketplaceCollections: undefined as MarketplaceCollection[] | undefined,
|
||||
marketplaceCollectionPluginsMap: undefined as Record<string, Plugin[]> | undefined,
|
||||
pluginCollections: undefined as PluginCollection[] | undefined,
|
||||
pluginCollectionPluginsMap: undefined as Record<string, Plugin[]> | undefined,
|
||||
isLoading: false,
|
||||
page: 1,
|
||||
},
|
||||
@@ -131,11 +131,10 @@ vi.mock('@/app/components/plugins/card', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock CardMoreInfo component
|
||||
vi.mock('@/app/components/plugins/card/card-more-info', () => ({
|
||||
default: ({ downloadCount, tags }: { downloadCount: number, tags: string[] }) => (
|
||||
<div data-testid="card-more-info">
|
||||
<span data-testid="download-count">{downloadCount}</span>
|
||||
// Mock CardTags component
|
||||
vi.mock('@/app/components/plugins/card/card-tags', () => ({
|
||||
default: ({ tags }: { tags: string[] }) => (
|
||||
<div data-testid="card-tags">
|
||||
<span data-testid="tags">{tags.join(',')}</span>
|
||||
</div>
|
||||
),
|
||||
@@ -208,7 +207,7 @@ const createMockPluginList = (count: number): Plugin[] =>
|
||||
label: { 'en-US': `Plugin ${i}` },
|
||||
}))
|
||||
|
||||
const createMockCollection = (overrides?: Partial<MarketplaceCollection>): MarketplaceCollection => ({
|
||||
const createMockCollection = (overrides?: Partial<PluginCollection>): PluginCollection => ({
|
||||
name: `collection-${Math.random().toString(36).substring(7)}`,
|
||||
label: { 'en-US': 'Test Collection' },
|
||||
description: { 'en-US': 'Test collection description' },
|
||||
@@ -220,7 +219,7 @@ const createMockCollection = (overrides?: Partial<MarketplaceCollection>): Marke
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createMockCollectionList = (count: number): MarketplaceCollection[] =>
|
||||
const createMockCollectionList = (count: number): PluginCollection[] =>
|
||||
Array.from({ length: count }, (_, i) =>
|
||||
createMockCollection({
|
||||
name: `collection-${i}`,
|
||||
@@ -233,8 +232,8 @@ const createMockCollectionList = (count: number): MarketplaceCollection[] =>
|
||||
// ================================
|
||||
describe('List', () => {
|
||||
const defaultProps = {
|
||||
marketplaceCollections: [] as MarketplaceCollection[],
|
||||
marketplaceCollectionPluginsMap: {} as Record<string, Plugin[]>,
|
||||
pluginCollections: [] as PluginCollection[],
|
||||
pluginCollectionPluginsMap: {} as Record<string, Plugin[]>,
|
||||
plugins: undefined,
|
||||
showInstallButton: false,
|
||||
cardContainerClassName: '',
|
||||
@@ -268,8 +267,8 @@ describe('List', () => {
|
||||
render(
|
||||
<List
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
pluginCollections={collections}
|
||||
pluginCollectionPluginsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
@@ -314,8 +313,8 @@ describe('List', () => {
|
||||
render(
|
||||
<List
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
pluginCollections={collections}
|
||||
pluginCollectionPluginsMap={pluginsMap}
|
||||
plugins={[]}
|
||||
/>,
|
||||
)
|
||||
@@ -426,12 +425,12 @@ describe('List', () => {
|
||||
// Edge Cases Tests
|
||||
// ================================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty marketplaceCollections', () => {
|
||||
it('should handle empty pluginCollections', () => {
|
||||
render(
|
||||
<List
|
||||
{...defaultProps}
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
/>,
|
||||
)
|
||||
|
||||
@@ -448,8 +447,8 @@ describe('List', () => {
|
||||
render(
|
||||
<List
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
pluginCollections={collections}
|
||||
pluginCollectionPluginsMap={pluginsMap}
|
||||
plugins={undefined}
|
||||
/>,
|
||||
)
|
||||
@@ -496,12 +495,12 @@ describe('List', () => {
|
||||
// ================================
|
||||
describe('ListWithCollection', () => {
|
||||
const defaultProps = {
|
||||
marketplaceCollections: [] as MarketplaceCollection[],
|
||||
marketplaceCollectionPluginsMap: {} as Record<string, Plugin[]>,
|
||||
variant: 'plugins' as const,
|
||||
collections: [] as PluginCollection[],
|
||||
collectionItemsMap: {} as Record<string, Plugin[]>,
|
||||
showInstallButton: false,
|
||||
cardContainerClassName: '',
|
||||
cardRender: undefined,
|
||||
onMoreClick: undefined,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -528,8 +527,8 @@ describe('ListWithCollection', () => {
|
||||
render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
@@ -548,8 +547,8 @@ describe('ListWithCollection', () => {
|
||||
render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
@@ -568,8 +567,8 @@ describe('ListWithCollection', () => {
|
||||
render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
@@ -584,19 +583,19 @@ describe('ListWithCollection', () => {
|
||||
describe('View More Button', () => {
|
||||
it('should render View More button when collection is searchable', () => {
|
||||
const collections = [createMockCollection({
|
||||
name: 'collection-0',
|
||||
name: 'partners',
|
||||
searchable: true,
|
||||
search_params: { query: 'test' },
|
||||
})]
|
||||
const pluginsMap: Record<string, Plugin[]> = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
partners: createMockPluginList(1),
|
||||
}
|
||||
|
||||
render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
@@ -615,8 +614,8 @@ describe('ListWithCollection', () => {
|
||||
render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
@@ -626,19 +625,19 @@ describe('ListWithCollection', () => {
|
||||
it('should call moreClick hook with search_params when View More is clicked', () => {
|
||||
const searchParams: SearchParamsFromCollection = { query: 'test-query', sort_by: 'install_count' }
|
||||
const collections = [createMockCollection({
|
||||
name: 'collection-0',
|
||||
name: 'partners',
|
||||
searchable: true,
|
||||
search_params: searchParams,
|
||||
})]
|
||||
const pluginsMap: Record<string, Plugin[]> = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
partners: createMockPluginList(1),
|
||||
}
|
||||
|
||||
render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
@@ -669,8 +668,8 @@ describe('ListWithCollection', () => {
|
||||
render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
cardRender={customCardRender}
|
||||
/>,
|
||||
)
|
||||
@@ -693,8 +692,8 @@ describe('ListWithCollection', () => {
|
||||
const { container } = render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
cardContainerClassName="custom-container"
|
||||
/>,
|
||||
)
|
||||
@@ -711,8 +710,8 @@ describe('ListWithCollection', () => {
|
||||
const { container } = render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
showInstallButton={true}
|
||||
/>,
|
||||
)
|
||||
@@ -730,8 +729,8 @@ describe('ListWithCollection', () => {
|
||||
render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
collections={[]}
|
||||
collectionItemsMap={{}}
|
||||
/>,
|
||||
)
|
||||
|
||||
@@ -746,8 +745,8 @@ describe('ListWithCollection', () => {
|
||||
render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
@@ -764,8 +763,8 @@ describe('ListWithCollection', () => {
|
||||
render(
|
||||
<ListWithCollection
|
||||
{...defaultProps}
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
@@ -784,8 +783,8 @@ describe('ListWrapper', () => {
|
||||
// Reset mock data
|
||||
mockMarketplaceData.plugins = undefined
|
||||
mockMarketplaceData.pluginsTotal = 0
|
||||
mockMarketplaceData.marketplaceCollections = undefined
|
||||
mockMarketplaceData.marketplaceCollectionPluginsMap = undefined
|
||||
mockMarketplaceData.pluginCollections = undefined
|
||||
mockMarketplaceData.pluginCollectionPluginsMap = undefined
|
||||
mockMarketplaceData.isLoading = false
|
||||
mockMarketplaceData.page = 1
|
||||
})
|
||||
@@ -862,8 +861,8 @@ describe('ListWrapper', () => {
|
||||
describe('List Rendering Logic', () => {
|
||||
it('should render collections when not loading', () => {
|
||||
mockMarketplaceData.isLoading = false
|
||||
mockMarketplaceData.marketplaceCollections = createMockCollectionList(1)
|
||||
mockMarketplaceData.marketplaceCollectionPluginsMap = {
|
||||
mockMarketplaceData.pluginCollections = createMockCollectionList(1)
|
||||
mockMarketplaceData.pluginCollectionPluginsMap = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
|
||||
@@ -875,8 +874,8 @@ describe('ListWrapper', () => {
|
||||
it('should render List when loading but page > 1', () => {
|
||||
mockMarketplaceData.isLoading = true
|
||||
mockMarketplaceData.page = 2
|
||||
mockMarketplaceData.marketplaceCollections = createMockCollectionList(1)
|
||||
mockMarketplaceData.marketplaceCollectionPluginsMap = {
|
||||
mockMarketplaceData.pluginCollections = createMockCollectionList(1)
|
||||
mockMarketplaceData.pluginCollectionPluginsMap = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
|
||||
@@ -900,13 +899,13 @@ describe('ListWrapper', () => {
|
||||
})
|
||||
|
||||
it('should show View More button and call moreClick hook', () => {
|
||||
mockMarketplaceData.marketplaceCollections = [createMockCollection({
|
||||
name: 'collection-0',
|
||||
mockMarketplaceData.pluginCollections = [createMockCollection({
|
||||
name: 'partners',
|
||||
searchable: true,
|
||||
search_params: { query: 'test' },
|
||||
})]
|
||||
mockMarketplaceData.marketplaceCollectionPluginsMap = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
mockMarketplaceData.pluginCollectionPluginsMap = {
|
||||
partners: createMockPluginList(1),
|
||||
}
|
||||
|
||||
render(<ListWrapper />)
|
||||
@@ -974,8 +973,8 @@ describe('CardWrapper (via List integration)', () => {
|
||||
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
/>,
|
||||
)
|
||||
@@ -983,7 +982,7 @@ describe('CardWrapper (via List integration)', () => {
|
||||
expect(screen.getByTestId('card-test-plugin')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render CardMoreInfo with download count and tags', () => {
|
||||
it('should render CardTags with tags', () => {
|
||||
const plugin = createMockPlugin({
|
||||
name: 'test-plugin',
|
||||
install_count: 5000,
|
||||
@@ -992,14 +991,13 @@ describe('CardWrapper (via List integration)', () => {
|
||||
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('card-more-info')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('download-count')).toHaveTextContent('5000')
|
||||
expect(screen.getByTestId('card-tags')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1012,8 +1010,8 @@ describe('CardWrapper (via List integration)', () => {
|
||||
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={plugins}
|
||||
/>,
|
||||
)
|
||||
@@ -1032,8 +1030,8 @@ describe('CardWrapper (via List integration)', () => {
|
||||
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
showInstallButton={true}
|
||||
/>,
|
||||
@@ -1052,8 +1050,8 @@ describe('CardWrapper (via List integration)', () => {
|
||||
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
showInstallButton={true}
|
||||
/>,
|
||||
@@ -1073,8 +1071,8 @@ describe('CardWrapper (via List integration)', () => {
|
||||
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
showInstallButton={true}
|
||||
/>,
|
||||
@@ -1091,8 +1089,8 @@ describe('CardWrapper (via List integration)', () => {
|
||||
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
showInstallButton={true}
|
||||
/>,
|
||||
@@ -1107,8 +1105,8 @@ describe('CardWrapper (via List integration)', () => {
|
||||
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
showInstallButton={true}
|
||||
/>,
|
||||
@@ -1123,8 +1121,8 @@ describe('CardWrapper (via List integration)', () => {
|
||||
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
showInstallButton={true}
|
||||
/>,
|
||||
@@ -1149,8 +1147,8 @@ describe('CardWrapper (via List integration)', () => {
|
||||
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
showInstallButton={false}
|
||||
/>,
|
||||
@@ -1169,8 +1167,8 @@ describe('CardWrapper (via List integration)', () => {
|
||||
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
showInstallButton={false}
|
||||
/>,
|
||||
@@ -1184,8 +1182,8 @@ describe('CardWrapper (via List integration)', () => {
|
||||
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
/>,
|
||||
)
|
||||
@@ -1207,8 +1205,8 @@ describe('CardWrapper (via List integration)', () => {
|
||||
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
/>,
|
||||
)
|
||||
@@ -1224,8 +1222,8 @@ describe('CardWrapper (via List integration)', () => {
|
||||
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
/>,
|
||||
)
|
||||
@@ -1241,8 +1239,8 @@ describe('CardWrapper (via List integration)', () => {
|
||||
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={[plugin]}
|
||||
/>,
|
||||
)
|
||||
@@ -1263,8 +1261,8 @@ describe('Combined Workflows', () => {
|
||||
mockMarketplaceData.pluginsTotal = 0
|
||||
mockMarketplaceData.isLoading = false
|
||||
mockMarketplaceData.page = 1
|
||||
mockMarketplaceData.marketplaceCollections = undefined
|
||||
mockMarketplaceData.marketplaceCollectionPluginsMap = undefined
|
||||
mockMarketplaceData.pluginCollections = undefined
|
||||
mockMarketplaceData.pluginCollectionPluginsMap = undefined
|
||||
})
|
||||
|
||||
it('should transition from loading to showing collections', async () => {
|
||||
@@ -1277,8 +1275,8 @@ describe('Combined Workflows', () => {
|
||||
|
||||
// Simulate loading complete
|
||||
mockMarketplaceData.isLoading = false
|
||||
mockMarketplaceData.marketplaceCollections = createMockCollectionList(1)
|
||||
mockMarketplaceData.marketplaceCollectionPluginsMap = {
|
||||
mockMarketplaceData.pluginCollections = createMockCollectionList(1)
|
||||
mockMarketplaceData.pluginCollectionPluginsMap = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
|
||||
@@ -1289,8 +1287,8 @@ describe('Combined Workflows', () => {
|
||||
})
|
||||
|
||||
it('should transition from collections to search results', async () => {
|
||||
mockMarketplaceData.marketplaceCollections = createMockCollectionList(1)
|
||||
mockMarketplaceData.marketplaceCollectionPluginsMap = {
|
||||
mockMarketplaceData.pluginCollections = createMockCollectionList(1)
|
||||
mockMarketplaceData.pluginCollectionPluginsMap = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
}
|
||||
|
||||
@@ -1352,8 +1350,9 @@ describe('Accessibility', () => {
|
||||
|
||||
const { container } = render(
|
||||
<ListWithCollection
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
variant="plugins"
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
@@ -1362,19 +1361,20 @@ describe('Accessibility', () => {
|
||||
expect(headings.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should have clickable View More button', () => {
|
||||
it('should have clickable View More button', () => {
|
||||
const collections = [createMockCollection({
|
||||
name: 'collection-0',
|
||||
name: 'partners',
|
||||
searchable: true,
|
||||
})]
|
||||
const pluginsMap: Record<string, Plugin[]> = {
|
||||
'collection-0': createMockPluginList(1),
|
||||
partners: createMockPluginList(1),
|
||||
}
|
||||
|
||||
render(
|
||||
<ListWithCollection
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
variant="plugins"
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
@@ -1383,18 +1383,18 @@ describe('Accessibility', () => {
|
||||
expect(viewMoreButton.closest('div')).toHaveClass('cursor-pointer')
|
||||
})
|
||||
|
||||
it('should have proper grid layout for cards', () => {
|
||||
it('should have proper grid layout for cards', () => {
|
||||
const plugins = createMockPluginList(4)
|
||||
|
||||
const { container } = render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={plugins}
|
||||
/>,
|
||||
)
|
||||
|
||||
const grid = container.querySelector('.grid-cols-4')
|
||||
const grid = container.querySelector('.grid')
|
||||
expect(grid).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1413,8 +1413,8 @@ describe('Performance', () => {
|
||||
const startTime = performance.now()
|
||||
render(
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
pluginCollections={[]}
|
||||
pluginCollectionPluginsMap={{}}
|
||||
plugins={plugins}
|
||||
/>,
|
||||
)
|
||||
@@ -1434,8 +1434,9 @@ describe('Performance', () => {
|
||||
const startTime = performance.now()
|
||||
render(
|
||||
<ListWithCollection
|
||||
marketplaceCollections={collections}
|
||||
marketplaceCollectionPluginsMap={pluginsMap}
|
||||
variant="plugins"
|
||||
collections={collections}
|
||||
collectionItemsMap={pluginsMap}
|
||||
/>,
|
||||
)
|
||||
const endTime = performance.now()
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import type { Plugin } from '../../types'
|
||||
import type { MarketplaceCollection } from '../types'
|
||||
import type { PluginCollection } from '../types'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Empty from '../empty'
|
||||
import CardWrapper from './card-wrapper'
|
||||
import { GRID_CLASS } from './collection-list'
|
||||
import ListWithCollection from './list-with-collection'
|
||||
|
||||
type ListProps = {
|
||||
marketplaceCollections: MarketplaceCollection[]
|
||||
marketplaceCollectionPluginsMap: Record<string, Plugin[]>
|
||||
pluginCollections: PluginCollection[]
|
||||
pluginCollectionPluginsMap: Record<string, Plugin[]>
|
||||
plugins?: Plugin[]
|
||||
showInstallButton?: boolean
|
||||
cardContainerClassName?: string
|
||||
@@ -16,8 +18,8 @@ type ListProps = {
|
||||
emptyClassName?: string
|
||||
}
|
||||
const List = ({
|
||||
marketplaceCollections,
|
||||
marketplaceCollectionPluginsMap,
|
||||
pluginCollections,
|
||||
pluginCollectionPluginsMap,
|
||||
plugins,
|
||||
showInstallButton,
|
||||
cardContainerClassName,
|
||||
@@ -29,8 +31,9 @@ const List = ({
|
||||
{
|
||||
!plugins && (
|
||||
<ListWithCollection
|
||||
marketplaceCollections={marketplaceCollections}
|
||||
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap}
|
||||
variant="plugins"
|
||||
collections={pluginCollections}
|
||||
collectionItemsMap={pluginCollectionPluginsMap}
|
||||
showInstallButton={showInstallButton}
|
||||
cardContainerClassName={cardContainerClassName}
|
||||
cardRender={cardRender}
|
||||
@@ -39,11 +42,7 @@ const List = ({
|
||||
}
|
||||
{
|
||||
plugins && !!plugins.length && (
|
||||
<div className={cn(
|
||||
'grid grid-cols-4 gap-3',
|
||||
cardContainerClassName,
|
||||
)}
|
||||
>
|
||||
<div className={cn(GRID_CLASS, cardContainerClassName)}>
|
||||
{
|
||||
plugins.map((plugin) => {
|
||||
if (cardRender)
|
||||
|
||||
@@ -1,83 +1,82 @@
|
||||
'use client'
|
||||
|
||||
import type { MarketplaceCollection } from '../types'
|
||||
import type { PluginCollection, Template, TemplateCollection } from '../types'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { useLocale, useTranslation } from '#i18n'
|
||||
import { RiArrowRightSLine } from '@remixicon/react'
|
||||
import { getLanguage } from '@/i18n-config/language'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useMarketplaceMoreClick } from '../atoms'
|
||||
import CardWrapper from './card-wrapper'
|
||||
import CollectionList, { CAROUSEL_COLLECTION_NAMES } from './collection-list'
|
||||
import TemplateCard from './template-card'
|
||||
|
||||
type ListWithCollectionProps = {
|
||||
marketplaceCollections: MarketplaceCollection[]
|
||||
marketplaceCollectionPluginsMap: Record<string, Plugin[]>
|
||||
showInstallButton?: boolean
|
||||
type BaseProps = {
|
||||
cardContainerClassName?: string
|
||||
}
|
||||
|
||||
type PluginsVariant = BaseProps & {
|
||||
variant: 'plugins'
|
||||
collections: PluginCollection[]
|
||||
collectionItemsMap: Record<string, Plugin[]>
|
||||
showInstallButton?: boolean
|
||||
cardRender?: (plugin: Plugin) => React.JSX.Element | null
|
||||
}
|
||||
const ListWithCollection = ({
|
||||
marketplaceCollections,
|
||||
marketplaceCollectionPluginsMap,
|
||||
showInstallButton,
|
||||
cardContainerClassName,
|
||||
cardRender,
|
||||
}: ListWithCollectionProps) => {
|
||||
const { t } = useTranslation()
|
||||
const locale = useLocale()
|
||||
const onMoreClick = useMarketplaceMoreClick()
|
||||
|
||||
type TemplatesVariant = BaseProps & {
|
||||
variant: 'templates'
|
||||
collections: TemplateCollection[]
|
||||
collectionItemsMap: Record<string, Template[]>
|
||||
}
|
||||
|
||||
type ListWithCollectionProps = PluginsVariant | TemplatesVariant
|
||||
|
||||
const ListWithCollection = (props: ListWithCollectionProps) => {
|
||||
const { variant, cardContainerClassName } = props
|
||||
|
||||
if (variant === 'plugins') {
|
||||
const {
|
||||
collections,
|
||||
collectionItemsMap,
|
||||
showInstallButton,
|
||||
cardRender,
|
||||
} = props
|
||||
|
||||
const renderPluginCard = (plugin: Plugin) => {
|
||||
if (cardRender)
|
||||
return cardRender(plugin)
|
||||
|
||||
return (
|
||||
<CardWrapper
|
||||
plugin={plugin}
|
||||
showInstallButton={showInstallButton}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<CollectionList
|
||||
collections={collections}
|
||||
collectionItemsMap={collectionItemsMap}
|
||||
itemKeyField="plugin_id"
|
||||
renderCard={renderPluginCard}
|
||||
carouselCollectionNames={[CAROUSEL_COLLECTION_NAMES.partners]}
|
||||
cardContainerClassName={cardContainerClassName}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const { collections, collectionItemsMap } = props
|
||||
|
||||
const renderTemplateCard = (template: Template) => (
|
||||
<TemplateCard template={template} />
|
||||
)
|
||||
|
||||
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 && (
|
||||
<div
|
||||
className="system-xs-medium flex cursor-pointer items-center text-text-accent "
|
||||
onClick={() => onMoreClick(collection.search_params)}
|
||||
>
|
||||
{t('marketplace.viewMore', { ns: 'plugin' })}
|
||||
<RiArrowRightSLine className="h-4 w-4" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<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}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</>
|
||||
<CollectionList
|
||||
collections={collections}
|
||||
collectionItemsMap={collectionItemsMap}
|
||||
itemKeyField="template_id"
|
||||
renderCard={renderTemplateCard}
|
||||
carouselCollectionNames={[CAROUSEL_COLLECTION_NAMES.featured]}
|
||||
viewMoreSearchTab="templates"
|
||||
cardContainerClassName={cardContainerClassName}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { Template } from '../types'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ListWrapper from './list-wrapper'
|
||||
|
||||
const { mockMarketplaceData } = vi.hoisted(() => ({
|
||||
mockMarketplaceData: {
|
||||
creationType: 'plugins' as 'plugins' | 'templates',
|
||||
isLoading: false,
|
||||
page: 1,
|
||||
isFetchingNextPage: false,
|
||||
pluginCollections: [],
|
||||
pluginCollectionPluginsMap: {},
|
||||
plugins: undefined as Plugin[] | undefined,
|
||||
templateCollections: [],
|
||||
templateCollectionTemplatesMap: {},
|
||||
templates: undefined as Template[] | undefined,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../state', () => ({
|
||||
useMarketplaceData: () => mockMarketplaceData,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/loading', () => ({
|
||||
default: () => <div data-testid="loading-component">Loading</div>,
|
||||
}))
|
||||
|
||||
vi.mock('./flat-list', () => ({
|
||||
default: ({ variant, items }: { variant: 'plugins' | 'templates', items: unknown[] }) => (
|
||||
<div data-testid={`flat-list-${variant}`}>
|
||||
{items.length}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./list-with-collection', () => ({
|
||||
default: ({ variant }: { variant: 'plugins' | 'templates' }) => (
|
||||
<div data-testid={`collection-list-${variant}`}>collection</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('ListWrapper flat rendering', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockMarketplaceData.creationType = 'plugins'
|
||||
mockMarketplaceData.isLoading = false
|
||||
mockMarketplaceData.page = 1
|
||||
mockMarketplaceData.isFetchingNextPage = false
|
||||
mockMarketplaceData.plugins = undefined
|
||||
mockMarketplaceData.templates = undefined
|
||||
})
|
||||
|
||||
it('renders plugin flat list when plugin items exist', () => {
|
||||
mockMarketplaceData.creationType = 'plugins'
|
||||
mockMarketplaceData.plugins = [{ org: 'o', name: 'p' } as Plugin]
|
||||
|
||||
render(<ListWrapper />)
|
||||
|
||||
expect(screen.getByTestId('flat-list-plugins')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collection-list-plugins')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders template flat list when template items exist', () => {
|
||||
mockMarketplaceData.creationType = 'templates'
|
||||
mockMarketplaceData.templates = [{ template_id: 't1' } as Template]
|
||||
|
||||
render(<ListWrapper />)
|
||||
|
||||
expect(screen.getByTestId('flat-list-templates')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collection-list-templates')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders template collection list when templates are undefined', () => {
|
||||
mockMarketplaceData.creationType = 'templates'
|
||||
mockMarketplaceData.templates = undefined
|
||||
|
||||
render(<ListWrapper />)
|
||||
|
||||
expect(screen.getByTestId('collection-list-templates')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('flat-list-templates')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,64 +1,57 @@
|
||||
'use client'
|
||||
import { useTranslation } from '#i18n'
|
||||
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import SortDropdown from '../sort-dropdown'
|
||||
import { useMarketplaceData } from '../state'
|
||||
import List from './index'
|
||||
import { isPluginsData, useMarketplaceData } from '../state'
|
||||
import FlatList from './flat-list'
|
||||
import ListWithCollection from './list-with-collection'
|
||||
|
||||
type ListWrapperProps = {
|
||||
showInstallButton?: boolean
|
||||
}
|
||||
const ListWrapper = ({
|
||||
showInstallButton,
|
||||
}: ListWrapperProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const {
|
||||
plugins,
|
||||
pluginsTotal,
|
||||
marketplaceCollections,
|
||||
marketplaceCollectionPluginsMap,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
page,
|
||||
} = useMarketplaceData()
|
||||
const ListWrapper = ({ showInstallButton }: ListWrapperProps) => {
|
||||
const marketplaceData = useMarketplaceData()
|
||||
const { isLoading, page, isFetchingNextPage } = marketplaceData
|
||||
|
||||
const renderContent = () => {
|
||||
if (isPluginsData(marketplaceData)) {
|
||||
const { pluginCollections, pluginCollectionPluginsMap, plugins } = marketplaceData
|
||||
return plugins !== undefined
|
||||
? <FlatList variant="plugins" items={plugins} showInstallButton={showInstallButton} />
|
||||
: (
|
||||
<ListWithCollection
|
||||
variant="plugins"
|
||||
collections={pluginCollections || []}
|
||||
collectionItemsMap={pluginCollectionPluginsMap || {}}
|
||||
showInstallButton={showInstallButton}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const { templateCollections, templateCollectionTemplatesMap, templates } = marketplaceData
|
||||
return templates !== undefined
|
||||
? <FlatList variant="templates" items={templates} />
|
||||
: (
|
||||
<ListWithCollection
|
||||
variant="templates"
|
||||
collections={templateCollections || []}
|
||||
collectionItemsMap={templateCollectionTemplatesMap || {}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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('marketplace.pluginsResult', { ns: 'plugin', num: pluginsTotal })}</div>
|
||||
<div className="mx-3 h-3.5 w-[1px] bg-divider-regular"></div>
|
||||
<SortDropdown />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
isLoading && page === 1 && (
|
||||
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
<Loading />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
(!isLoading || page > 1) && (
|
||||
<List
|
||||
marketplaceCollections={marketplaceCollections || []}
|
||||
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap || {}}
|
||||
plugins={plugins}
|
||||
showInstallButton={showInstallButton}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
isFetchingNextPage && (
|
||||
<Loading className="my-3" />
|
||||
)
|
||||
}
|
||||
{isLoading && page === 1 && (
|
||||
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
<Loading />
|
||||
</div>
|
||||
)}
|
||||
{(!isLoading || page > 1) && renderContent()}
|
||||
{isFetchingNextPage && <Loading className="my-3" />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
180
web/app/components/plugins/marketplace/list/template-card.tsx
Normal file
180
web/app/components/plugins/marketplace/list/template-card.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
'use client'
|
||||
|
||||
import type { Template } from '../types'
|
||||
import { useLocale } from '#i18n'
|
||||
import Image from 'next/image'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { getLanguage } from '@/i18n-config/language'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { formatUsedCount } from '@/utils/template'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
|
||||
type TemplateCardProps = {
|
||||
template: Template
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Number of tag icons to show before showing "+X"
|
||||
const MAX_VISIBLE_TAGS = 7
|
||||
|
||||
// Soft background color palette for avatar
|
||||
const AVATAR_BG_COLORS = [
|
||||
'bg-components-icon-bg-red-soft',
|
||||
'bg-components-icon-bg-orange-dark-soft',
|
||||
'bg-components-icon-bg-yellow-soft',
|
||||
'bg-components-icon-bg-green-soft',
|
||||
'bg-components-icon-bg-teal-soft',
|
||||
'bg-components-icon-bg-blue-light-soft',
|
||||
'bg-components-icon-bg-blue-soft',
|
||||
'bg-components-icon-bg-indigo-soft',
|
||||
'bg-components-icon-bg-violet-soft',
|
||||
'bg-components-icon-bg-pink-soft',
|
||||
]
|
||||
|
||||
// Simple hash function to get consistent color per template
|
||||
const getAvatarBgClass = (id: string): string => {
|
||||
let hash = 0
|
||||
for (let i = 0; i < id.length; i++) {
|
||||
const char = id.charCodeAt(i)
|
||||
hash = ((hash << 5) - hash) + char
|
||||
hash = hash & hash
|
||||
}
|
||||
return AVATAR_BG_COLORS[Math.abs(hash) % AVATAR_BG_COLORS.length]
|
||||
}
|
||||
|
||||
const TemplateCardComponent = ({
|
||||
template,
|
||||
className,
|
||||
}: TemplateCardProps) => {
|
||||
const locale = useLocale()
|
||||
const { theme } = useTheme()
|
||||
const { template_id, name, description, icon, tags, author, used_count, icon_background } = template as Template & { used_count?: number, icon_background?: string }
|
||||
const isIconUrl = !!icon && /^(?:https?:)?\/\//.test(icon)
|
||||
|
||||
const avatarBgStyle = useMemo(() => {
|
||||
// If icon_background is provided (hex or rgba), use it directly
|
||||
if (icon_background)
|
||||
return { backgroundColor: icon_background }
|
||||
return undefined
|
||||
}, [icon_background])
|
||||
|
||||
const avatarBgClass = useMemo(() => {
|
||||
// Only use class-based color if no inline style
|
||||
if (icon_background)
|
||||
return ''
|
||||
return getAvatarBgClass(template_id)
|
||||
}, [icon_background, template_id])
|
||||
|
||||
const descriptionText = description[getLanguage(locale)] || description.en_US || ''
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
const url = getMarketplaceUrl(`/templates/${author}/${name}`, {
|
||||
theme,
|
||||
language: locale,
|
||||
templateId: template_id,
|
||||
})
|
||||
window.open(url, '_blank')
|
||||
}, [author, name, theme, locale, template_id])
|
||||
|
||||
const visibleTags = tags?.slice(0, MAX_VISIBLE_TAGS) || []
|
||||
const remainingTagsCount = tags ? Math.max(0, tags.length - MAX_VISIBLE_TAGS) : 0
|
||||
|
||||
const formattedUsedCount = formatUsedCount(used_count, { precision: 0, rounding: 'floor' })
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'hover-bg-components-panel-on-panel-item-bg relative flex h-full cursor-pointer flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg pb-3 shadow-xs',
|
||||
className,
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex shrink-0 items-center gap-3 px-4 pb-2 pt-4">
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-[10px] border-[0.5px] border-divider-regular p-1',
|
||||
avatarBgClass,
|
||||
)}
|
||||
style={avatarBgStyle}
|
||||
>
|
||||
{isIconUrl
|
||||
? (
|
||||
<Image
|
||||
src={icon}
|
||||
alt={name}
|
||||
width={24}
|
||||
height={24}
|
||||
className="h-6 w-6 object-contain"
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<span className="text-2xl leading-[1.2]">{icon || '📄'}</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Title */}
|
||||
<div className="flex min-w-0 flex-1 flex-col justify-center gap-0.5">
|
||||
<p className="system-md-medium truncate text-text-primary">{name}</p>
|
||||
<div className="system-xs-regular flex items-center gap-2 text-text-tertiary">
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<span>by</span>
|
||||
<span className="truncate">{author}</span>
|
||||
</span>
|
||||
{formattedUsedCount && (
|
||||
<>
|
||||
<span className="shrink-0">·</span>
|
||||
<span className="shrink-0">
|
||||
{formattedUsedCount}
|
||||
{' '}
|
||||
used
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="shrink-0 px-4 pb-2 pt-1">
|
||||
<p
|
||||
className="system-xs-regular line-clamp-2 min-h-[32px] text-text-secondary"
|
||||
title={descriptionText}
|
||||
>
|
||||
{descriptionText}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Bottom Info Bar - Tags as icons */}
|
||||
<div className="mt-auto flex min-h-7 shrink-0 items-center gap-1 px-4 py-1">
|
||||
{tags && tags.length > 0 && (
|
||||
<>
|
||||
{visibleTags.map((tag, index) => (
|
||||
<div
|
||||
key={`${template_id}-tag-${index}`}
|
||||
className="flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded-md border-[0.5px] border-effects-icon-border bg-background-default-dodge"
|
||||
title={tag}
|
||||
>
|
||||
<span className="text-sm">{tag}</span>
|
||||
</div>
|
||||
))}
|
||||
{remainingTagsCount > 0 && (
|
||||
<div className="flex items-center justify-center p-0.5">
|
||||
<span className="system-xs-regular text-text-tertiary">
|
||||
+
|
||||
{remainingTagsCount}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TemplateCard = React.memo(TemplateCardComponent)
|
||||
|
||||
export default TemplateCard
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import { useSearchTab } from './atoms'
|
||||
import ListWrapper from './list/list-wrapper'
|
||||
import SearchPage from './search-page'
|
||||
|
||||
type MarketplaceContentProps = {
|
||||
showInstallButton?: boolean
|
||||
}
|
||||
|
||||
const MarketplaceContent = ({ showInstallButton }: MarketplaceContentProps) => {
|
||||
const [searchTab] = useSearchTab()
|
||||
|
||||
if (searchTab)
|
||||
return <SearchPage />
|
||||
return <ListWrapper showInstallButton={showInstallButton} />
|
||||
}
|
||||
|
||||
export default MarketplaceContent
|
||||
@@ -0,0 +1,21 @@
|
||||
'use client'
|
||||
|
||||
import { useSearchTab } from './atoms'
|
||||
import { Description } from './description'
|
||||
import SearchResultsHeader from './search-results-header'
|
||||
|
||||
type MarketplaceHeaderProps = {
|
||||
descriptionClassName?: string
|
||||
marketplaceNav?: React.ReactNode
|
||||
}
|
||||
|
||||
const MarketplaceHeader = ({ descriptionClassName, marketplaceNav }: MarketplaceHeaderProps) => {
|
||||
const [searchTab] = useSearchTab()
|
||||
|
||||
if (searchTab)
|
||||
return <SearchResultsHeader marketplaceNav={marketplaceNav} />
|
||||
|
||||
return <Description className={descriptionClassName} marketplaceNav={marketplaceNav} />
|
||||
}
|
||||
|
||||
export default MarketplaceHeader
|
||||
21
web/app/components/plugins/marketplace/plugin-type-icons.tsx
Normal file
21
web/app/components/plugins/marketplace/plugin-type-icons.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { ComponentType } from 'react'
|
||||
import {
|
||||
RiBrain2Line,
|
||||
RiDatabase2Line,
|
||||
RiHammerLine,
|
||||
RiPuzzle2Line,
|
||||
RiSpeakAiLine,
|
||||
} from '@remixicon/react'
|
||||
import { Trigger as TriggerIcon } from '@/app/components/base/icons/src/vender/plugin'
|
||||
import { PluginCategoryEnum } from '../types'
|
||||
|
||||
export type PluginTypeIconComponent = ComponentType<{ className?: string }>
|
||||
|
||||
export const MARKETPLACE_TYPE_ICON_COMPONENTS: Record<PluginCategoryEnum, PluginTypeIconComponent> = {
|
||||
[PluginCategoryEnum.tool]: RiHammerLine,
|
||||
[PluginCategoryEnum.model]: RiBrain2Line,
|
||||
[PluginCategoryEnum.datasource]: RiDatabase2Line,
|
||||
[PluginCategoryEnum.trigger]: TriggerIcon,
|
||||
[PluginCategoryEnum.agent]: RiSpeakAiLine,
|
||||
[PluginCategoryEnum.extension]: RiPuzzle2Line,
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
'use client'
|
||||
import type { ActivePluginType } from './constants'
|
||||
import { useTranslation } from '#i18n'
|
||||
import {
|
||||
RiArchive2Line,
|
||||
RiBrain2Line,
|
||||
RiDatabase2Line,
|
||||
RiHammerLine,
|
||||
RiPuzzle2Line,
|
||||
RiSpeakAiLine,
|
||||
} from '@remixicon/react'
|
||||
import { useSetAtom } from 'jotai'
|
||||
import { Trigger as TriggerIcon } from '@/app/components/base/icons/src/vender/plugin'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { searchModeAtom, useActivePluginType } from './atoms'
|
||||
import { PLUGIN_CATEGORY_WITH_COLLECTIONS, PLUGIN_TYPE_SEARCH_MAP } from './constants'
|
||||
|
||||
type PluginTypeSwitchProps = {
|
||||
className?: string
|
||||
}
|
||||
const PluginTypeSwitch = ({
|
||||
className,
|
||||
}: PluginTypeSwitchProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [activePluginType, handleActivePluginTypeChange] = useActivePluginType()
|
||||
const setSearchMode = useSetAtom(searchModeAtom)
|
||||
|
||||
const options: Array<{
|
||||
value: ActivePluginType
|
||||
text: string
|
||||
icon: React.ReactNode | null
|
||||
}> = [
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.all,
|
||||
text: t('category.all', { ns: 'plugin' }),
|
||||
icon: null,
|
||||
},
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.model,
|
||||
text: t('category.models', { ns: 'plugin' }),
|
||||
icon: <RiBrain2Line className="mr-1.5 h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.tool,
|
||||
text: t('category.tools', { ns: 'plugin' }),
|
||||
icon: <RiHammerLine className="mr-1.5 h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.datasource,
|
||||
text: t('category.datasources', { ns: 'plugin' }),
|
||||
icon: <RiDatabase2Line className="mr-1.5 h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.trigger,
|
||||
text: t('category.triggers', { ns: 'plugin' }),
|
||||
icon: <TriggerIcon className="mr-1.5 h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.agent,
|
||||
text: t('category.agents', { ns: 'plugin' }),
|
||||
icon: <RiSpeakAiLine className="mr-1.5 h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.extension,
|
||||
text: t('category.extensions', { ns: 'plugin' }),
|
||||
icon: <RiPuzzle2Line className="mr-1.5 h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.bundle,
|
||||
text: t('category.bundles', { ns: 'plugin' }),
|
||||
icon: <RiArchive2Line className="mr-1.5 h-4 w-4" />,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center justify-center space-x-2 bg-background-body py-3',
|
||||
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)
|
||||
if (PLUGIN_CATEGORY_WITH_COLLECTIONS.has(option.value)) {
|
||||
setSearchMode(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{option.icon}
|
||||
{option.text}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PluginTypeSwitch
|
||||
@@ -1,23 +1,37 @@
|
||||
import type { PluginsSearchParams } from './types'
|
||||
import type { CreatorSearchParams, PluginsSearchParams, TemplateSearchParams, UnifiedSearchParams } from './types'
|
||||
import type { MarketPlaceInputs } from '@/contract/router'
|
||||
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
|
||||
import { marketplaceQuery } from '@/service/client'
|
||||
import { getMarketplaceCollectionsAndPlugins, getMarketplacePlugins } from './utils'
|
||||
import { getMarketplaceCollectionsAndPlugins, getMarketplaceCreators, getMarketplacePlugins, getMarketplaceTemplateCollectionsAndTemplates, getMarketplaceTemplates, getMarketplaceUnifiedSearch } from './utils'
|
||||
|
||||
export function useMarketplaceCollectionsAndPlugins(
|
||||
collectionsParams: MarketPlaceInputs['collections']['query'],
|
||||
collectionsParams: MarketPlaceInputs['plugins']['collections']['query'],
|
||||
options?: { enabled?: boolean },
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: marketplaceQuery.collections.queryKey({ input: { query: collectionsParams } }),
|
||||
queryKey: marketplaceQuery.plugins.collections.queryKey({ input: { query: collectionsParams } }),
|
||||
queryFn: ({ signal }) => getMarketplaceCollectionsAndPlugins(collectionsParams, { signal }),
|
||||
enabled: options?.enabled !== false,
|
||||
})
|
||||
}
|
||||
|
||||
export function useMarketplaceTemplateCollectionsAndTemplates(
|
||||
query?: { page?: number, page_size?: number, condition?: string },
|
||||
options?: { enabled?: boolean },
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: marketplaceQuery.templateCollections.list.queryKey({ input: { query } }),
|
||||
queryFn: ({ signal }) => getMarketplaceTemplateCollectionsAndTemplates(query, { signal }),
|
||||
enabled: options?.enabled !== false,
|
||||
})
|
||||
}
|
||||
|
||||
export function useMarketplacePlugins(
|
||||
queryParams: PluginsSearchParams | undefined,
|
||||
options?: { enabled?: boolean },
|
||||
) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: marketplaceQuery.searchAdvanced.queryKey({
|
||||
queryKey: marketplaceQuery.plugins.searchAdvanced.queryKey({
|
||||
input: {
|
||||
body: queryParams!,
|
||||
params: { kind: queryParams?.type === 'bundle' ? 'bundles' : 'plugins' },
|
||||
@@ -30,6 +44,59 @@ export function useMarketplacePlugins(
|
||||
return loaded < (lastPage.total || 0) ? nextPage : undefined
|
||||
},
|
||||
initialPageParam: 1,
|
||||
enabled: options?.enabled !== false && queryParams !== undefined,
|
||||
})
|
||||
}
|
||||
|
||||
export function useMarketplaceTemplates(
|
||||
queryParams: TemplateSearchParams | undefined,
|
||||
options?: { enabled?: boolean },
|
||||
) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: marketplaceQuery.templates.searchAdvanced.queryKey({
|
||||
input: {
|
||||
body: queryParams!,
|
||||
},
|
||||
}),
|
||||
queryFn: ({ pageParam = 1, signal }) => getMarketplaceTemplates(queryParams, pageParam, signal),
|
||||
getNextPageParam: (lastPage) => {
|
||||
const nextPage = lastPage.page + 1
|
||||
const loaded = lastPage.page * lastPage.page_size
|
||||
return loaded < (lastPage.total || 0) ? nextPage : undefined
|
||||
},
|
||||
initialPageParam: 1,
|
||||
enabled: options?.enabled !== false && queryParams !== undefined,
|
||||
})
|
||||
}
|
||||
|
||||
export function useMarketplaceCreators(
|
||||
queryParams: CreatorSearchParams | undefined,
|
||||
) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: marketplaceQuery.creators.searchAdvanced.queryKey({
|
||||
input: {
|
||||
body: queryParams!,
|
||||
},
|
||||
}),
|
||||
queryFn: ({ pageParam = 1, signal }) => getMarketplaceCreators(queryParams, pageParam, signal),
|
||||
getNextPageParam: (lastPage) => {
|
||||
const nextPage = lastPage.page + 1
|
||||
const loaded = lastPage.page * lastPage.page_size
|
||||
return loaded < (lastPage.total || 0) ? nextPage : undefined
|
||||
},
|
||||
initialPageParam: 1,
|
||||
enabled: queryParams !== undefined,
|
||||
})
|
||||
}
|
||||
|
||||
export function useMarketplaceUnifiedSearch(
|
||||
queryParams: UnifiedSearchParams | undefined,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: marketplaceQuery.searchUnified.queryKey({
|
||||
input: { body: queryParams! },
|
||||
}),
|
||||
queryFn: ({ signal }) => getMarketplaceUnifiedSearch(queryParams, signal),
|
||||
enabled: queryParams !== undefined,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { Tag } from '@/app/components/plugins/hooks'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PluginCategoryEnum } from '../../types'
|
||||
import SearchBox from './index'
|
||||
import SearchBoxWrapper from './search-box-wrapper'
|
||||
import SearchDropdown from './search-dropdown'
|
||||
import MarketplaceTrigger from './trigger/marketplace'
|
||||
import ToolSelectorTrigger from './trigger/tool-selector'
|
||||
|
||||
@@ -13,34 +16,85 @@ import ToolSelectorTrigger from './trigger/tool-selector'
|
||||
// Mock i18n translation hook
|
||||
vi.mock('#i18n', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string }) => {
|
||||
t: (key: string, options?: { ns?: string, num?: number, author?: string }) => {
|
||||
// Build full key with namespace prefix if provided
|
||||
const fullKey = options?.ns ? `${options.ns}.${key}` : key
|
||||
const translations: Record<string, string> = {
|
||||
'pluginTags.allTags': 'All Tags',
|
||||
'pluginTags.searchTags': 'Search tags',
|
||||
'plugin.searchPlugins': 'Search plugins',
|
||||
'plugin.install': `${options?.num || 0} installs`,
|
||||
'plugin.marketplace.searchDropdown.plugins': 'Plugins',
|
||||
'plugin.marketplace.searchDropdown.showAllResults': 'Show all search results',
|
||||
'plugin.marketplace.searchDropdown.enter': 'Enter',
|
||||
'plugin.marketplace.searchDropdown.byAuthor': `by ${options?.author || ''}`,
|
||||
}
|
||||
return translations[fullKey] || key
|
||||
},
|
||||
}),
|
||||
useLocale: () => 'en-US',
|
||||
}))
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useDebounce: (value: string) => value,
|
||||
}))
|
||||
|
||||
vi.mock('jotai', async () => {
|
||||
const actual = await vi.importActual<typeof import('jotai')>('jotai')
|
||||
return {
|
||||
...actual,
|
||||
useSetAtom: () => vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/hooks/use-i18n', () => ({
|
||||
useRenderI18nObject: () => (value: Record<string, string> | string) => {
|
||||
if (typeof value === 'string')
|
||||
return value
|
||||
return value.en_US || Object.values(value)[0] || ''
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock marketplace state hooks
|
||||
const { mockSearchPluginText, mockHandleSearchPluginTextChange, mockFilterPluginTags, mockHandleFilterPluginTagsChange } = vi.hoisted(() => {
|
||||
const {
|
||||
mockSearchText,
|
||||
mockHandleSearchTextChange,
|
||||
mockFilterPluginTags,
|
||||
mockHandleFilterPluginTagsChange,
|
||||
mockActivePluginCategory,
|
||||
mockSortValue,
|
||||
} = vi.hoisted(() => {
|
||||
return {
|
||||
mockSearchPluginText: '',
|
||||
mockHandleSearchPluginTextChange: vi.fn(),
|
||||
mockSearchText: '',
|
||||
mockHandleSearchTextChange: vi.fn(),
|
||||
mockFilterPluginTags: [] as string[],
|
||||
mockHandleFilterPluginTagsChange: vi.fn(),
|
||||
mockActivePluginCategory: 'all',
|
||||
mockSortValue: {
|
||||
sortBy: 'install_count',
|
||||
sortOrder: 'DESC',
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../atoms', () => ({
|
||||
useSearchPluginText: () => [mockSearchPluginText, mockHandleSearchPluginTextChange],
|
||||
useSearchText: () => [mockSearchText, mockHandleSearchTextChange],
|
||||
useFilterPluginTags: () => [mockFilterPluginTags, mockHandleFilterPluginTagsChange],
|
||||
useActivePluginCategory: () => [mockActivePluginCategory, vi.fn()],
|
||||
useMarketplaceSortValue: () => mockSortValue,
|
||||
searchModeAtom: {},
|
||||
}))
|
||||
|
||||
vi.mock('../utils', async () => {
|
||||
const actual = await vi.importActual<typeof import('../utils')>('../utils')
|
||||
return {
|
||||
...actual,
|
||||
mapUnifiedPluginToPlugin: (item: Plugin) => item,
|
||||
mapUnifiedTemplateToTemplate: (item: unknown) => item,
|
||||
mapUnifiedCreatorToCreator: (item: unknown) => item,
|
||||
}
|
||||
})
|
||||
|
||||
// Mock useTags hook
|
||||
const mockTags: Tag[] = [
|
||||
{ name: 'agent', label: 'Agent' },
|
||||
@@ -60,8 +114,64 @@ vi.mock('@/app/components/plugins/hooks', () => ({
|
||||
tags: mockTags,
|
||||
tagsMap: mockTagsMap,
|
||||
}),
|
||||
useCategories: () => ({
|
||||
categoriesMap: {
|
||||
'tool': { name: 'tool', label: 'Tool' },
|
||||
'model': { name: 'model', label: 'Model' },
|
||||
'datasource': { name: 'datasource', label: 'Data Source' },
|
||||
'trigger': { name: 'trigger', label: 'Trigger' },
|
||||
'agent-strategy': { name: 'agent-strategy', label: 'Agent Strategy' },
|
||||
'extension': { name: 'extension', label: 'Extension' },
|
||||
'bundle': { name: 'bundle', label: 'Bundle' },
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
let mockDropdownPlugins: Plugin[] = []
|
||||
vi.mock('../query', () => ({
|
||||
useMarketplaceUnifiedSearch: () => ({
|
||||
data: {
|
||||
plugins: { items: mockDropdownPlugins, total: mockDropdownPlugins.length },
|
||||
templates: { items: [], total: 0 },
|
||||
creators: { items: [], total: 0 },
|
||||
organizations: { items: [], total: 0 },
|
||||
page: 1,
|
||||
page_size: 5,
|
||||
},
|
||||
isLoading: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
const createPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
|
||||
type: 'plugin',
|
||||
org: 'dropbox',
|
||||
author: 'dropbox',
|
||||
name: 'dropbox-search',
|
||||
plugin_id: 'plugin-1',
|
||||
version: '1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
latest_package_identifier: 'pkg-1',
|
||||
icon: 'https://example.com/icon.png',
|
||||
verified: false,
|
||||
label: { en_US: 'Dropbox search' },
|
||||
brief: { en_US: 'Interact with Dropbox files.' },
|
||||
description: { en_US: 'Interact with Dropbox files.' },
|
||||
introduction: '',
|
||||
repository: '',
|
||||
category: PluginCategoryEnum.tool,
|
||||
install_count: 206,
|
||||
endpoint: {
|
||||
settings: [],
|
||||
},
|
||||
tags: [],
|
||||
badges: [],
|
||||
verification: {
|
||||
authorized_category: 'community',
|
||||
},
|
||||
from: 'marketplace',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// Mock portal-to-follow-elem with shared open state
|
||||
let mockPortalOpenState = false
|
||||
|
||||
@@ -115,6 +225,7 @@ describe('SearchBox', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPortalOpenState = false
|
||||
mockDropdownPlugins = []
|
||||
})
|
||||
|
||||
// ================================
|
||||
@@ -424,6 +535,68 @@ describe('SearchBox', () => {
|
||||
expect(onSearchChange).toHaveBeenCalledWith(' ')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Submission Tests
|
||||
// ================================
|
||||
describe('Submission', () => {
|
||||
it('should call onSearchSubmit when pressing Enter', () => {
|
||||
const onSearchSubmit = vi.fn()
|
||||
render(<SearchBox {...defaultProps} onSearchSubmit={onSearchSubmit} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
expect(onSearchSubmit).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// SearchDropdown Component Tests
|
||||
// ================================
|
||||
describe('SearchDropdown', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render plugin items and metadata', () => {
|
||||
render(
|
||||
<SearchDropdown
|
||||
query="dropbox"
|
||||
plugins={[createPlugin()]}
|
||||
templates={[]}
|
||||
creators={[]}
|
||||
onShowAll={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Plugins')).toBeInTheDocument()
|
||||
expect(screen.getByText('Dropbox search')).toBeInTheDocument()
|
||||
expect(screen.getByText('Tool')).toBeInTheDocument()
|
||||
expect(screen.getByText('206 installs')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Interactions', () => {
|
||||
it('should call onShowAll when clicking show all results', () => {
|
||||
const onShowAll = vi.fn()
|
||||
render(
|
||||
<SearchDropdown
|
||||
query="dropbox"
|
||||
plugins={[createPlugin()]}
|
||||
templates={[]}
|
||||
creators={[]}
|
||||
onShowAll={onShowAll}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Show all search results'))
|
||||
|
||||
expect(onShowAll).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
@@ -433,6 +606,7 @@ describe('SearchBoxWrapper', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPortalOpenState = false
|
||||
mockDropdownPlugins = []
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
@@ -457,13 +631,47 @@ describe('SearchBoxWrapper', () => {
|
||||
})
|
||||
|
||||
describe('Hook Integration', () => {
|
||||
it('should call handleSearchPluginTextChange when search changes', () => {
|
||||
it('should not commit search when input changes', () => {
|
||||
render(<SearchBoxWrapper />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'new search' } })
|
||||
|
||||
expect(mockHandleSearchPluginTextChange).toHaveBeenCalledWith('new search')
|
||||
expect(mockHandleSearchTextChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should commit search when pressing Enter', () => {
|
||||
render(<SearchBoxWrapper />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'new search' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
expect(mockHandleSearchTextChange).toHaveBeenCalledWith('new search')
|
||||
})
|
||||
|
||||
it('should clear committed search when input is emptied and blurred', () => {
|
||||
render(<SearchBoxWrapper />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
// Focus, type something, then clear and blur
|
||||
fireEvent.focus(input)
|
||||
fireEvent.change(input, { target: { value: 'test query' } })
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(mockHandleSearchTextChange).toHaveBeenCalledWith('')
|
||||
})
|
||||
|
||||
it('should not clear committed search when input has content and blurred', () => {
|
||||
render(<SearchBoxWrapper />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.focus(input)
|
||||
fireEvent.change(input, { target: { value: 'still has text' } })
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(mockHandleSearchTextChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ import TagsFilter from './tags-filter'
|
||||
type SearchBoxProps = {
|
||||
search: string
|
||||
onSearchChange: (search: string) => void
|
||||
onSearchSubmit?: () => void
|
||||
onSearchFocus?: () => void
|
||||
onSearchBlur?: () => void
|
||||
wrapperClassName?: string
|
||||
inputClassName?: string
|
||||
tags: string[]
|
||||
@@ -22,6 +25,9 @@ type SearchBoxProps = {
|
||||
const SearchBox = ({
|
||||
search,
|
||||
onSearchChange,
|
||||
onSearchSubmit,
|
||||
onSearchFocus,
|
||||
onSearchBlur,
|
||||
wrapperClassName,
|
||||
inputClassName,
|
||||
tags,
|
||||
@@ -58,6 +64,12 @@ const SearchBox = ({
|
||||
onChange={(e) => {
|
||||
onSearchChange(e.target.value)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter')
|
||||
onSearchSubmit?.()
|
||||
}}
|
||||
onFocus={onSearchFocus}
|
||||
onBlur={onSearchBlur}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
{
|
||||
@@ -89,6 +101,12 @@ const SearchBox = ({
|
||||
onChange={(e) => {
|
||||
onSearchChange(e.target.value)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter')
|
||||
onSearchSubmit?.()
|
||||
}}
|
||||
onFocus={onSearchFocus}
|
||||
onBlur={onSearchBlur}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
{
|
||||
|
||||
@@ -1,25 +1,135 @@
|
||||
'use client'
|
||||
|
||||
import type { UnifiedSearchParams } from '../types'
|
||||
import { useTranslation } from '#i18n'
|
||||
import { useFilterPluginTags, useSearchPluginText } from '../atoms'
|
||||
import SearchBox from './index'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { useSetAtom } from 'jotai'
|
||||
import { useMemo, useState } from 'react'
|
||||
import Input from '@/app/components/base/input'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import {
|
||||
searchModeAtom,
|
||||
useSearchText,
|
||||
} from '../atoms'
|
||||
import { useMarketplaceUnifiedSearch } from '../query'
|
||||
import { mapUnifiedCreatorToCreator, mapUnifiedPluginToPlugin, mapUnifiedTemplateToTemplate } from '../utils'
|
||||
import SearchDropdown from './search-dropdown'
|
||||
|
||||
const SearchBoxWrapper = () => {
|
||||
type SearchBoxWrapperProps = {
|
||||
wrapperClassName?: string
|
||||
inputClassName?: string
|
||||
}
|
||||
const SearchBoxWrapper = ({
|
||||
wrapperClassName,
|
||||
inputClassName,
|
||||
}: SearchBoxWrapperProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [searchPluginText, handleSearchPluginTextChange] = useSearchPluginText()
|
||||
const [filterPluginTags, handleFilterPluginTagsChange] = useFilterPluginTags()
|
||||
const [searchText, handleSearchTextChange] = useSearchText()
|
||||
const setSearchMode = useSetAtom(searchModeAtom)
|
||||
const committedSearch = searchText || ''
|
||||
const [draftSearch, setDraftSearch] = useState(committedSearch)
|
||||
const [isFocused, setIsFocused] = useState(false)
|
||||
const [isHoveringDropdown, setIsHoveringDropdown] = useState(false)
|
||||
const debouncedDraft = useDebounce(draftSearch, { wait: 300 })
|
||||
const hasDraft = !!debouncedDraft.trim()
|
||||
|
||||
const dropdownQueryParams = useMemo((): UnifiedSearchParams | undefined => {
|
||||
if (!hasDraft)
|
||||
return undefined
|
||||
return {
|
||||
query: debouncedDraft.trim(),
|
||||
scope: ['plugins', 'templates', 'creators'],
|
||||
page_size: 5,
|
||||
}
|
||||
}, [debouncedDraft, hasDraft])
|
||||
|
||||
const dropdownQuery = useMarketplaceUnifiedSearch(dropdownQueryParams)
|
||||
const dropdownPlugins = useMemo(
|
||||
() => (dropdownQuery.data?.plugins.items || []).map(mapUnifiedPluginToPlugin),
|
||||
[dropdownQuery.data?.plugins.items],
|
||||
)
|
||||
const dropdownTemplates = useMemo(
|
||||
() => (dropdownQuery.data?.templates.items || []).map(mapUnifiedTemplateToTemplate),
|
||||
[dropdownQuery.data?.templates.items],
|
||||
)
|
||||
const dropdownCreators = useMemo(
|
||||
() => (dropdownQuery.data?.creators.items || []).map(mapUnifiedCreatorToCreator),
|
||||
[dropdownQuery.data?.creators.items],
|
||||
)
|
||||
|
||||
const handleSubmit = () => {
|
||||
const trimmed = draftSearch.trim()
|
||||
if (!trimmed)
|
||||
return
|
||||
handleSearchTextChange(trimmed)
|
||||
setSearchMode(true)
|
||||
setIsFocused(false)
|
||||
}
|
||||
|
||||
const inputValue = isFocused ? draftSearch : committedSearch
|
||||
const isDropdownOpen = hasDraft && (isFocused || isHoveringDropdown)
|
||||
|
||||
return (
|
||||
<SearchBox
|
||||
wrapperClassName="z-[11] mx-auto w-[640px] shrink-0"
|
||||
inputClassName="w-full"
|
||||
search={searchPluginText}
|
||||
onSearchChange={handleSearchPluginTextChange}
|
||||
tags={filterPluginTags}
|
||||
onTagsChange={handleFilterPluginTagsChange}
|
||||
placeholder={t('searchPlugins', { ns: 'plugin' })}
|
||||
usedInMarketplace
|
||||
/>
|
||||
<PortalToFollowElem
|
||||
placement="bottom-start"
|
||||
offset={8}
|
||||
open={isDropdownOpen}
|
||||
onOpenChange={setIsFocused}
|
||||
>
|
||||
<PortalToFollowElemTrigger asChild>
|
||||
<div>
|
||||
<Input
|
||||
wrapperClassName={cn('w-[200px] rounded-lg lg:w-[300px]', wrapperClassName)}
|
||||
className={cn('h-9 bg-components-input-bg-normal', inputClassName)}
|
||||
showLeftIcon
|
||||
value={inputValue}
|
||||
placeholder={t('searchPlugins', { ns: 'plugin' })}
|
||||
onChange={(e) => {
|
||||
setDraftSearch(e.target.value)
|
||||
}}
|
||||
onFocus={() => {
|
||||
setDraftSearch(committedSearch)
|
||||
setIsFocused(true)
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (!isHoveringDropdown) {
|
||||
if (!draftSearch.trim()) {
|
||||
handleSearchTextChange('')
|
||||
setSearchMode(null)
|
||||
}
|
||||
setIsFocused(false)
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter')
|
||||
handleSubmit()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent
|
||||
className="z-[1001]"
|
||||
onMouseEnter={() => setIsHoveringDropdown(true)}
|
||||
onMouseLeave={() => setIsHoveringDropdown(false)}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
}}
|
||||
>
|
||||
<SearchDropdown
|
||||
query={debouncedDraft.trim()}
|
||||
plugins={dropdownPlugins}
|
||||
templates={dropdownTemplates}
|
||||
creators={dropdownCreators}
|
||||
onShowAll={handleSubmit}
|
||||
isLoading={dropdownQuery.isLoading}
|
||||
/>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import type { Creator, Template } from '../../types'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import type { Locale } from '@/i18n-config/language'
|
||||
import { useLocale, useTranslation } from '#i18n'
|
||||
import { RiArrowRightLine } from '@remixicon/react'
|
||||
import { Fragment } from 'react'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { useCategories } from '@/app/components/plugins/hooks'
|
||||
import { useRenderI18nObject } from '@/hooks/use-i18n'
|
||||
import { getLanguage } from '@/i18n-config/language'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
import { MARKETPLACE_TYPE_ICON_COMPONENTS } from '../../plugin-type-icons'
|
||||
import { getCreatorAvatarUrl, getPluginDetailLinkInMarketplace } from '../../utils'
|
||||
|
||||
const DROPDOWN_PANEL = 'w-[472px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-xl backdrop-blur-sm'
|
||||
const ICON_BOX_BASE = 'flex shrink-0 items-center justify-center overflow-hidden border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge'
|
||||
|
||||
const SectionDivider = () => (
|
||||
<div className="border-t border-divider-subtle" />
|
||||
)
|
||||
|
||||
const DropdownSection = ({ title, children }: { title: string, children: React.ReactNode }) => (
|
||||
<div className="p-1">
|
||||
<div className="system-xs-semibold-uppercase px-3 pb-2 pt-3 text-text-primary">{title}</div>
|
||||
<div className="flex flex-col">{children}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const DropdownItem = ({ href, icon, children }: {
|
||||
href: string
|
||||
icon: React.ReactNode
|
||||
children: React.ReactNode
|
||||
}) => (
|
||||
<a className="flex gap-1 rounded-lg py-1 pl-3 pr-1 hover:bg-state-base-hover" href={href}>
|
||||
{icon}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5 p-1">{children}</div>
|
||||
</a>
|
||||
)
|
||||
|
||||
const IconBox = ({ shape, size = 'sm', className, style, children }: {
|
||||
shape: 'rounded-lg' | 'rounded-full'
|
||||
size?: 'sm' | 'md'
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
children: React.ReactNode
|
||||
}) => (
|
||||
<div
|
||||
className={cn(
|
||||
ICON_BOX_BASE,
|
||||
shape,
|
||||
size === 'sm' ? 'h-7 w-7' : 'h-8 w-8',
|
||||
className,
|
||||
)}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
const ItemMeta = ({ items }: { items: (React.ReactNode | string)[] }) => (
|
||||
<div className="flex items-center gap-1.5 pt-1 text-text-tertiary">
|
||||
{items.filter(Boolean).map((item, i) => (
|
||||
<Fragment key={i}>
|
||||
{i > 0 && <span className="system-xs-regular">·</span>}
|
||||
{typeof item === 'string' ? <span className="system-xs-regular">{item}</span> : item}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
type SearchDropdownProps = {
|
||||
query: string
|
||||
plugins: Plugin[]
|
||||
templates: Template[]
|
||||
creators: Creator[]
|
||||
onShowAll: () => void
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
const SearchDropdown = ({
|
||||
query,
|
||||
plugins,
|
||||
templates,
|
||||
creators,
|
||||
onShowAll,
|
||||
isLoading = false,
|
||||
}: SearchDropdownProps) => {
|
||||
const { t } = useTranslation()
|
||||
const locale = useLocale()
|
||||
const getValueFromI18nObject = useRenderI18nObject()
|
||||
const { categoriesMap } = useCategories(true)
|
||||
|
||||
const hasResults = plugins.length > 0 || templates.length > 0 || creators.length > 0
|
||||
|
||||
// Collect rendered sections with dividers between them
|
||||
const sections: React.ReactNode[] = []
|
||||
|
||||
if (templates.length > 0) {
|
||||
sections.push(
|
||||
<TemplatesSection
|
||||
key="templates"
|
||||
templates={templates}
|
||||
locale={locale}
|
||||
t={t}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
if (plugins.length > 0) {
|
||||
sections.push(
|
||||
<PluginsSection
|
||||
key="plugins"
|
||||
plugins={plugins}
|
||||
getValueFromI18nObject={getValueFromI18nObject}
|
||||
categoriesMap={categoriesMap}
|
||||
t={t}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
if (creators.length > 0) {
|
||||
sections.push(
|
||||
<CreatorsSection
|
||||
key="creators"
|
||||
creators={creators}
|
||||
t={t}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={DROPDOWN_PANEL}>
|
||||
<div className="flex flex-col">
|
||||
{isLoading && !hasResults && (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loading />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sections.map((section, i) => (
|
||||
<Fragment key={i}>
|
||||
{i > 0 && <SectionDivider />}
|
||||
{section}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t border-divider-subtle p-1">
|
||||
<button
|
||||
className="group flex w-full items-center justify-between rounded-lg px-3 py-2 text-left hover:bg-state-base-hover"
|
||||
onClick={onShowAll}
|
||||
type="button"
|
||||
>
|
||||
<span className="system-sm-medium text-text-accent">
|
||||
{t('marketplace.searchDropdown.showAllResults', { ns: 'plugin', query })}
|
||||
</span>
|
||||
<span className="flex items-center">
|
||||
<span className="system-2xs-medium-uppercase rounded-[5px] border border-divider-deep px-1.5 py-0.5 text-text-tertiary group-hover:hidden">
|
||||
{t('marketplace.searchDropdown.enter', { ns: 'plugin' })}
|
||||
</span>
|
||||
<RiArrowRightLine className="hidden h-[18px] w-[18px] text-text-accent group-hover:block" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---------- Templates Section ---------- */
|
||||
|
||||
function TemplatesSection({ templates, locale, t }: {
|
||||
templates: Template[]
|
||||
locale: Locale
|
||||
t: ReturnType<typeof useTranslation>['t']
|
||||
}) {
|
||||
return (
|
||||
<DropdownSection title={t('templates', { ns: 'plugin' })}>
|
||||
{templates.map((template) => {
|
||||
const descriptionText = template.description[getLanguage(locale)] || template.description.en_US || ''
|
||||
const iconBgStyle = template.icon_background
|
||||
? { backgroundColor: template.icon_background }
|
||||
: undefined
|
||||
return (
|
||||
<DropdownItem
|
||||
key={template.template_id}
|
||||
href={getMarketplaceUrl(`/templates/${template.template_id}`)}
|
||||
icon={(
|
||||
<div className="flex shrink-0 items-start py-1">
|
||||
<IconBox shape="rounded-lg" style={iconBgStyle}>
|
||||
<span className="text-xl leading-[1.2]">{template.icon || '📄'}</span>
|
||||
</IconBox>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="system-md-medium truncate text-text-primary">{template.name}</div>
|
||||
{!!descriptionText && (
|
||||
<div className="system-xs-regular line-clamp-2 text-text-tertiary">{descriptionText}</div>
|
||||
)}
|
||||
<ItemMeta
|
||||
items={[
|
||||
t('marketplace.searchDropdown.byAuthor', { ns: 'plugin', author: template.author }),
|
||||
...(template.tags.length > 0
|
||||
? [<span key="tags" className="system-xs-regular truncate">{template.tags.join(', ')}</span>]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</DropdownItem>
|
||||
)
|
||||
})}
|
||||
</DropdownSection>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---------- Plugins Section ---------- */
|
||||
|
||||
function PluginsSection({ plugins, getValueFromI18nObject, categoriesMap, t }: {
|
||||
plugins: Plugin[]
|
||||
getValueFromI18nObject: ReturnType<typeof useRenderI18nObject>
|
||||
categoriesMap: Record<string, { label: string }>
|
||||
t: ReturnType<typeof useTranslation>['t']
|
||||
}) {
|
||||
return (
|
||||
<DropdownSection title={t('marketplace.searchDropdown.plugins', { ns: 'plugin' })}>
|
||||
{plugins.map((plugin) => {
|
||||
const title = getValueFromI18nObject(plugin.label) || plugin.name
|
||||
const description = getValueFromI18nObject(plugin.brief) || ''
|
||||
const categoryLabel = categoriesMap[plugin.category]?.label || plugin.category
|
||||
const installLabel = t('install', { ns: 'plugin', num: plugin.install_count || 0 })
|
||||
const author = plugin.org || plugin.author || ''
|
||||
const TypeIcon = MARKETPLACE_TYPE_ICON_COMPONENTS[plugin.category]
|
||||
const categoryNode = (
|
||||
<div className="flex items-center gap-1">
|
||||
{TypeIcon && <TypeIcon className="h-[14px] w-[14px] text-text-tertiary" />}
|
||||
<span className="system-xs-regular">{categoryLabel}</span>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<DropdownItem
|
||||
key={`${plugin.org}/${plugin.name}`}
|
||||
href={getPluginDetailLinkInMarketplace(plugin)}
|
||||
icon={(
|
||||
<div className="flex shrink-0 items-start py-1">
|
||||
<IconBox shape="rounded-lg">
|
||||
<img className="h-full w-full object-cover" src={plugin.icon} alt={title} />
|
||||
</IconBox>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="system-md-medium truncate text-text-primary">{title}</div>
|
||||
{!!description && (
|
||||
<div className="system-xs-regular line-clamp-2 text-text-tertiary">{description}</div>
|
||||
)}
|
||||
<ItemMeta
|
||||
items={[
|
||||
categoryNode,
|
||||
t('marketplace.searchDropdown.byAuthor', { ns: 'plugin', author }),
|
||||
installLabel,
|
||||
]}
|
||||
/>
|
||||
</DropdownItem>
|
||||
)
|
||||
})}
|
||||
</DropdownSection>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---------- Creators Section ---------- */
|
||||
|
||||
function CreatorsSection({ creators, t }: {
|
||||
creators: Creator[]
|
||||
t: ReturnType<typeof useTranslation>['t']
|
||||
}) {
|
||||
return (
|
||||
<DropdownSection title={t('marketplace.searchFilterCreators', { ns: 'plugin' })}>
|
||||
{creators.map(creator => (
|
||||
<a
|
||||
key={creator.unique_handle}
|
||||
className="flex items-center gap-2 rounded-lg px-3 py-2 hover:bg-state-base-hover"
|
||||
href={getMarketplaceUrl(`/creators/${creator.unique_handle}`)}
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full border-[0.5px] border-divider-regular">
|
||||
<img
|
||||
className="h-full w-full object-cover"
|
||||
src={getCreatorAvatarUrl(creator.unique_handle)}
|
||||
alt={creator.display_name}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-px">
|
||||
<div className="system-md-medium truncate text-text-primary">{creator.display_name}</div>
|
||||
<div className="system-xs-regular truncate text-text-tertiary">
|
||||
@
|
||||
{creator.unique_handle}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</DropdownSection>
|
||||
)
|
||||
}
|
||||
|
||||
export default SearchDropdown
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as MarketplaceTrigger } from './marketplace'
|
||||
export { default as ToolSelectorTrigger } from './tool-selector'
|
||||
@@ -0,0 +1,60 @@
|
||||
'use client'
|
||||
|
||||
import type { Creator } from '../types'
|
||||
import { getCreatorAvatarUrl } from '../utils'
|
||||
import { useTranslation } from '#i18n'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
|
||||
type CreatorCardProps = {
|
||||
creator: Creator
|
||||
}
|
||||
|
||||
const CreatorCard = ({ creator }: CreatorCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const href = getMarketplaceUrl(`/creators/${creator.unique_handle}`)
|
||||
const displayName = creator.display_name || creator.name
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex flex-col gap-2 rounded-xl border border-components-panel-border-subtle bg-components-panel-bg p-4 transition-colors hover:bg-state-base-hover"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-12 w-12 shrink-0 overflow-hidden rounded-full border border-components-panel-border-subtle bg-background-default-dodge">
|
||||
<img
|
||||
src={getCreatorAvatarUrl(creator.unique_handle)}
|
||||
alt={displayName}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="system-md-medium truncate text-text-primary">{displayName}</div>
|
||||
<div className="system-sm-regular text-text-tertiary">
|
||||
@
|
||||
{creator.unique_handle}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!!creator.description && (
|
||||
<div className="system-sm-regular line-clamp-2 text-text-secondary">
|
||||
{creator.description}
|
||||
</div>
|
||||
)}
|
||||
{(creator.plugin_count !== undefined || creator.template_count !== undefined) && (
|
||||
<div className="system-xs-regular text-text-tertiary">
|
||||
{creator.plugin_count || 0}
|
||||
{' '}
|
||||
{t('plugins', { ns: 'plugin' }).toLowerCase()}
|
||||
{' · '}
|
||||
{creator.template_count || 0}
|
||||
{' '}
|
||||
{t('templates', { ns: 'plugin' }).toLowerCase()}
|
||||
</div>
|
||||
)}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreatorCard
|
||||
266
web/app/components/plugins/marketplace/search-page/index.tsx
Normal file
266
web/app/components/plugins/marketplace/search-page/index.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
'use client'
|
||||
|
||||
import type { SearchTab } from '../search-params'
|
||||
import type { Creator, PluginsSearchParams, Template } from '../types'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { useTranslation } from '#i18n'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import SegmentedControl from '@/app/components/base/segmented-control'
|
||||
import { useMarketplaceSortValue, useSearchTab, useSearchText } from '../atoms'
|
||||
import { PLUGIN_TYPE_SEARCH_MAP } from '../constants'
|
||||
import Empty from '../empty'
|
||||
import { useMarketplaceContainerScroll } from '../hooks'
|
||||
import CardWrapper from '../list/card-wrapper'
|
||||
import TemplateCard from '../list/template-card'
|
||||
import { useMarketplaceCreators, useMarketplacePlugins, useMarketplaceTemplates } from '../query'
|
||||
import SortDropdown from '../sort-dropdown'
|
||||
import { getPluginFilterType, mapTemplateDetailToTemplate } from '../utils'
|
||||
import CreatorCard from './creator-card'
|
||||
|
||||
const PAGE_SIZE = 40
|
||||
const ZERO_WIDTH_SPACE = '\u200B'
|
||||
|
||||
type SortValue = { sortBy: string, sortOrder: string }
|
||||
|
||||
function mapSortForTemplates(sort: SortValue): { sort_by: string, sort_order: string } {
|
||||
const sortBy = sort.sortBy === 'install_count' ? 'usage_count' : sort.sortBy === 'version_updated_at' ? 'updated_at' : sort.sortBy
|
||||
return { sort_by: sortBy, sort_order: sort.sortOrder }
|
||||
}
|
||||
|
||||
function mapSortForCreators(sort: SortValue): { sort_by: string, sort_order: string } {
|
||||
const sortBy = sort.sortBy === 'install_count' ? 'created_at' : sort.sortBy === 'version_updated_at' ? 'updated_at' : sort.sortBy
|
||||
return { sort_by: sortBy, sort_order: sort.sortOrder }
|
||||
}
|
||||
|
||||
const SearchPage = () => {
|
||||
const { t } = useTranslation()
|
||||
const [searchText] = useSearchText()
|
||||
const debouncedQuery = useDebounce(searchText, { wait: 500 })
|
||||
const [searchTabParam, setSearchTab] = useSearchTab()
|
||||
const searchTab = (searchTabParam || 'all') as SearchTab
|
||||
const sort = useMarketplaceSortValue()
|
||||
|
||||
const query = debouncedQuery === ZERO_WIDTH_SPACE ? '' : debouncedQuery.trim()
|
||||
const hasQuery = !!searchText && (!!query || searchText === ZERO_WIDTH_SPACE)
|
||||
|
||||
const pluginsParams = useMemo(() => {
|
||||
if (!hasQuery)
|
||||
return undefined
|
||||
return {
|
||||
query,
|
||||
page_size: searchTab === 'all' ? 6 : PAGE_SIZE,
|
||||
sort_by: sort.sortBy,
|
||||
sort_order: sort.sortOrder,
|
||||
type: getPluginFilterType(PLUGIN_TYPE_SEARCH_MAP.all),
|
||||
} as PluginsSearchParams
|
||||
}, [hasQuery, query, searchTab, sort])
|
||||
|
||||
const templatesParams = useMemo(() => {
|
||||
if (!hasQuery)
|
||||
return undefined
|
||||
const { sort_by, sort_order } = mapSortForTemplates(sort)
|
||||
return {
|
||||
query,
|
||||
page_size: searchTab === 'all' ? 6 : PAGE_SIZE,
|
||||
sort_by,
|
||||
sort_order,
|
||||
}
|
||||
}, [hasQuery, query, searchTab, sort])
|
||||
|
||||
const creatorsParams = useMemo(() => {
|
||||
if (!hasQuery)
|
||||
return undefined
|
||||
const { sort_by, sort_order } = mapSortForCreators(sort)
|
||||
return {
|
||||
query,
|
||||
page_size: searchTab === 'all' ? 6 : PAGE_SIZE,
|
||||
sort_by,
|
||||
sort_order,
|
||||
}
|
||||
}, [hasQuery, query, searchTab, sort])
|
||||
|
||||
const fetchPlugins = searchTab === 'all' || searchTab === 'plugins'
|
||||
const fetchTemplates = searchTab === 'all' || searchTab === 'templates'
|
||||
const fetchCreators = searchTab === 'all' || searchTab === 'creators'
|
||||
|
||||
const pluginsQuery = useMarketplacePlugins(fetchPlugins ? pluginsParams : undefined)
|
||||
const templatesQuery = useMarketplaceTemplates(fetchTemplates ? templatesParams : undefined)
|
||||
const creatorsQuery = useMarketplaceCreators(fetchCreators ? creatorsParams : undefined)
|
||||
|
||||
const plugins = pluginsQuery.data?.pages.flatMap(p => p.plugins) ?? []
|
||||
const pluginsTotal = pluginsQuery.data?.pages[0]?.total ?? 0
|
||||
const templates = useMemo(
|
||||
() => (templatesQuery.data?.pages.flatMap(p => p.templates) ?? []).map(mapTemplateDetailToTemplate),
|
||||
[templatesQuery.data],
|
||||
)
|
||||
const templatesTotal = templatesQuery.data?.pages[0]?.total ?? 0
|
||||
const creators = creatorsQuery.data?.pages.flatMap(p => p.creators) ?? []
|
||||
const creatorsTotal = creatorsQuery.data?.pages[0]?.total ?? 0
|
||||
|
||||
const handleScrollLoadMore = useCallback(() => {
|
||||
if (searchTab === 'plugins' && pluginsQuery.hasNextPage && !pluginsQuery.isFetching)
|
||||
pluginsQuery.fetchNextPage()
|
||||
else if (searchTab === 'templates' && templatesQuery.hasNextPage && !templatesQuery.isFetching)
|
||||
templatesQuery.fetchNextPage()
|
||||
else if (searchTab === 'creators' && creatorsQuery.hasNextPage && !creatorsQuery.isFetching)
|
||||
creatorsQuery.fetchNextPage()
|
||||
}, [searchTab, pluginsQuery, templatesQuery, creatorsQuery])
|
||||
|
||||
useMarketplaceContainerScroll(handleScrollLoadMore)
|
||||
|
||||
const tabOptions = [
|
||||
{ value: 'all', text: t('marketplace.searchFilterAll', { ns: 'plugin' }), count: pluginsTotal + templatesTotal + creatorsTotal },
|
||||
{ value: 'templates', text: t('templates', { ns: 'plugin' }), count: templatesTotal },
|
||||
{ value: 'plugins', text: t('plugins', { ns: 'plugin' }), count: pluginsTotal },
|
||||
{ value: 'creators', text: t('marketplace.searchFilterCreators', { ns: 'plugin' }), count: creatorsTotal },
|
||||
]
|
||||
|
||||
const isLoading = (fetchPlugins && pluginsQuery.isLoading)
|
||||
|| (fetchTemplates && templatesQuery.isLoading)
|
||||
|| (fetchCreators && creatorsQuery.isLoading)
|
||||
const isFetchingNextPage = pluginsQuery.isFetchingNextPage
|
||||
|| templatesQuery.isFetchingNextPage
|
||||
|| creatorsQuery.isFetchingNextPage
|
||||
|
||||
const renderPluginsSection = (items: Plugin[], limit?: number) => {
|
||||
const toShow = limit ? items.slice(0, limit) : items
|
||||
if (toShow.length === 0)
|
||||
return null
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{toShow.map(plugin => (
|
||||
<CardWrapper key={`${plugin.org}/${plugin.name}`} plugin={plugin} showInstallButton={false} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderTemplatesSection = (items: Template[], limit?: number) => {
|
||||
const toShow = limit ? items.slice(0, limit) : items
|
||||
if (toShow.length === 0)
|
||||
return null
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{toShow.map(template => (
|
||||
<div key={template.template_id}>
|
||||
<TemplateCard template={template} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderCreatorsSection = (items: Creator[], limit?: number) => {
|
||||
const toShow = limit ? items.slice(0, limit) : items
|
||||
if (toShow.length === 0)
|
||||
return null
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{toShow.map(creator => (
|
||||
<CreatorCard key={creator.unique_handle} creator={creator} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderAllTab = () => (
|
||||
<div className="flex flex-col gap-8 py-4">
|
||||
{templates.length > 0 && (
|
||||
<section>
|
||||
<h3 className="title-xl-semi-bold mb-3 text-text-primary">
|
||||
{t('templates', { ns: 'plugin' })}
|
||||
</h3>
|
||||
{renderTemplatesSection(templates, 6)}
|
||||
</section>
|
||||
)}
|
||||
{plugins.length > 0 && (
|
||||
<section>
|
||||
<h3 className="title-xl-semi-bold mb-3 text-text-primary">
|
||||
{t('plugins', { ns: 'plugin' })}
|
||||
</h3>
|
||||
{renderPluginsSection(plugins, 6)}
|
||||
</section>
|
||||
)}
|
||||
{creators.length > 0 && (
|
||||
<section>
|
||||
<h3 className="title-xl-semi-bold mb-3 text-text-primary">
|
||||
{t('marketplace.searchFilterCreators', { ns: 'plugin' })}
|
||||
</h3>
|
||||
{renderCreatorsSection(creators, 6)}
|
||||
</section>
|
||||
)}
|
||||
{!isLoading && plugins.length === 0 && templates.length === 0 && creators.length === 0 && (
|
||||
<Empty />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderPluginsTab = () => {
|
||||
if (plugins.length === 0 && !pluginsQuery.isLoading)
|
||||
return <Empty />
|
||||
return (
|
||||
<div className="py-4">
|
||||
{renderPluginsSection(plugins)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderTemplatesTab = () => {
|
||||
if (templates.length === 0 && !templatesQuery.isLoading)
|
||||
return <Empty />
|
||||
return (
|
||||
<div className="py-4">
|
||||
{renderTemplatesSection(templates)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderCreatorsTab = () => {
|
||||
if (creators.length === 0 && !creatorsQuery.isLoading)
|
||||
return <Empty />
|
||||
return (
|
||||
<div className="py-4">
|
||||
{renderCreatorsSection(creators)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ scrollbarGutter: 'stable' }}
|
||||
className="relative flex grow flex-col bg-background-default-subtle px-12 py-2"
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between pt-3">
|
||||
<SegmentedControl
|
||||
size="large"
|
||||
activeState="accentLight"
|
||||
value={searchTab}
|
||||
onChange={v => setSearchTab(v as SearchTab)}
|
||||
options={tabOptions}
|
||||
/>
|
||||
<SortDropdown />
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
<Loading />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && (
|
||||
<>
|
||||
{searchTab === 'all' && renderAllTab()}
|
||||
{searchTab === 'plugins' && renderPluginsTab()}
|
||||
{searchTab === 'templates' && renderTemplatesTab()}
|
||||
{searchTab === 'creators' && renderCreatorsTab()}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isFetchingNextPage && <Loading className="my-3" />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SearchPage
|
||||
@@ -1,9 +1,18 @@
|
||||
import type { ActivePluginType } from './constants'
|
||||
import { parseAsArrayOf, parseAsString, parseAsStringEnum } from 'nuqs/server'
|
||||
import { PLUGIN_TYPE_SEARCH_MAP } from './constants'
|
||||
|
||||
export const CREATION_TYPE = {
|
||||
plugins: 'plugins',
|
||||
templates: 'templates',
|
||||
} as const
|
||||
|
||||
export type CreationType = typeof CREATION_TYPE[keyof typeof CREATION_TYPE]
|
||||
|
||||
export const marketplaceSearchParamsParsers = {
|
||||
category: parseAsStringEnum<ActivePluginType>(Object.values(PLUGIN_TYPE_SEARCH_MAP) as ActivePluginType[]).withDefault('all').withOptions({ history: 'replace', clearOnDefault: false }),
|
||||
category: parseAsString.withDefault('all').withOptions({ history: 'replace', clearOnDefault: false }),
|
||||
q: parseAsString.withDefault('').withOptions({ history: 'replace' }),
|
||||
tags: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }),
|
||||
creationType: parseAsStringEnum<CreationType>([CREATION_TYPE.plugins, CREATION_TYPE.templates]).withDefault(CREATION_TYPE.plugins).withOptions({ history: 'replace' }),
|
||||
searchTab: parseAsStringEnum<SearchTab>(['all', 'plugins', 'templates', 'creators']).withDefault('').withOptions({ history: 'replace' }),
|
||||
}
|
||||
|
||||
export type SearchTab = 'all' | 'plugins' | 'templates' | 'creators' | ''
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslation } from '#i18n'
|
||||
import { useSearchText } from './atoms'
|
||||
|
||||
type SearchResultsHeaderProps = {
|
||||
marketplaceNav?: React.ReactNode
|
||||
}
|
||||
const SearchResultsHeader = ({ marketplaceNav }: SearchResultsHeaderProps) => {
|
||||
const { t } = useTranslation('plugin')
|
||||
const [searchText] = useSearchText()
|
||||
|
||||
return (
|
||||
<div className="relative px-7 py-4">
|
||||
{marketplaceNav}
|
||||
<div className="system-xs-regular mt-8 flex items-center gap-1 px-5 text-text-tertiary ">
|
||||
<span>{t('marketplace.searchBreadcrumbMarketplace')}</span>
|
||||
<span className="text-text-quaternary">/</span>
|
||||
<span>{t('marketplace.searchBreadcrumbSearch')}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex items-end gap-2 px-5 ">
|
||||
<div className="title-4xl-semi-bold text-text-primary">
|
||||
{t('marketplace.searchResultsFor')}
|
||||
</div>
|
||||
<div className="title-4xl-semi-bold relative text-saas-dify-blue-accessible">
|
||||
<span className="relative z-10">{searchText || ''}</span>
|
||||
<span className="absolute bottom-0 left-0 right-0 h-3 bg-saas-dify-blue-accessible opacity-10" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SearchResultsHeader
|
||||
@@ -1,20 +1,32 @@
|
||||
import type { PluginsSearchParams } from './types'
|
||||
import type { PluginsSearchParams, TemplateSearchParams } from './types'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useActivePluginType, useFilterPluginTags, useMarketplaceSearchMode, useMarketplaceSortValue, useSearchPluginText } from './atoms'
|
||||
import { PLUGIN_TYPE_SEARCH_MAP } from './constants'
|
||||
import { useActivePluginCategory, useActiveTemplateCategory, useCreationType, useFilterPluginTags, useMarketplaceSearchMode, useMarketplaceSortValue, useSearchText } from './atoms'
|
||||
import { CATEGORY_ALL } from './constants'
|
||||
import { useMarketplaceContainerScroll } from './hooks'
|
||||
import { useMarketplaceCollectionsAndPlugins, useMarketplacePlugins } from './query'
|
||||
import { getCollectionsParams, getMarketplaceListFilterType } from './utils'
|
||||
import { useMarketplaceCollectionsAndPlugins, useMarketplacePlugins, useMarketplaceTemplateCollectionsAndTemplates, useMarketplaceTemplates } from './query'
|
||||
import { CREATION_TYPE } from './search-params'
|
||||
import { getCollectionsParams, getPluginFilterType, mapTemplateDetailToTemplate } from './utils'
|
||||
|
||||
export function useMarketplaceData() {
|
||||
const [searchPluginTextOriginal] = useSearchPluginText()
|
||||
const searchPluginText = useDebounce(searchPluginTextOriginal, { wait: 500 })
|
||||
const getCategory = (category: string) => {
|
||||
if (category === CATEGORY_ALL)
|
||||
return undefined
|
||||
return category
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for plugins marketplace data
|
||||
* Only fetches plugins-related data
|
||||
*/
|
||||
export function usePluginsMarketplaceData(enabled = true) {
|
||||
const [searchTextOriginal] = useSearchText()
|
||||
const searchText = useDebounce(searchTextOriginal, { wait: 500 })
|
||||
const [filterPluginTags] = useFilterPluginTags()
|
||||
const [activePluginType] = useActivePluginType()
|
||||
const [activePluginCategory] = useActivePluginCategory()
|
||||
|
||||
const collectionsQuery = useMarketplaceCollectionsAndPlugins(
|
||||
getCollectionsParams(activePluginType),
|
||||
const pluginsCollectionsQuery = useMarketplaceCollectionsAndPlugins(
|
||||
getCollectionsParams(activePluginCategory),
|
||||
{ enabled },
|
||||
)
|
||||
|
||||
const sort = useMarketplaceSortValue()
|
||||
@@ -23,16 +35,16 @@ export function useMarketplaceData() {
|
||||
if (!isSearchMode)
|
||||
return undefined
|
||||
return {
|
||||
query: searchPluginText,
|
||||
category: activePluginType === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginType,
|
||||
query: searchText,
|
||||
category: getCategory(activePluginCategory),
|
||||
tags: filterPluginTags,
|
||||
sort_by: sort.sortBy,
|
||||
sort_order: sort.sortOrder,
|
||||
type: getMarketplaceListFilterType(activePluginType),
|
||||
type: getPluginFilterType(activePluginCategory),
|
||||
}
|
||||
}, [isSearchMode, searchPluginText, activePluginType, filterPluginTags, sort])
|
||||
}, [isSearchMode, searchText, activePluginCategory, filterPluginTags, sort])
|
||||
|
||||
const pluginsQuery = useMarketplacePlugins(queryParams)
|
||||
const pluginsQuery = useMarketplacePlugins(queryParams, { enabled })
|
||||
const { hasNextPage, fetchNextPage, isFetching, isFetchingNextPage } = pluginsQuery
|
||||
|
||||
const handlePageChange = useCallback(() => {
|
||||
@@ -44,12 +56,87 @@ export function useMarketplaceData() {
|
||||
useMarketplaceContainerScroll(handlePageChange)
|
||||
|
||||
return {
|
||||
marketplaceCollections: collectionsQuery.data?.marketplaceCollections,
|
||||
marketplaceCollectionPluginsMap: collectionsQuery.data?.marketplaceCollectionPluginsMap,
|
||||
pluginCollections: pluginsCollectionsQuery.data?.marketplaceCollections,
|
||||
pluginCollectionPluginsMap: pluginsCollectionsQuery.data?.marketplaceCollectionPluginsMap,
|
||||
plugins: pluginsQuery.data?.pages.flatMap(page => page.plugins),
|
||||
pluginsTotal: pluginsQuery.data?.pages[0]?.total,
|
||||
page: pluginsQuery.data?.pages.length || 1,
|
||||
isLoading: collectionsQuery.isLoading || pluginsQuery.isLoading,
|
||||
isLoading: pluginsCollectionsQuery.isLoading || pluginsQuery.isLoading,
|
||||
isFetchingNextPage,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for templates marketplace data
|
||||
* Only fetches templates-related data
|
||||
*/
|
||||
export function useTemplatesMarketplaceData(enabled = true) {
|
||||
// Reuse existing atoms for search and sort
|
||||
const [searchTextOriginal] = useSearchText()
|
||||
const searchText = useDebounce(searchTextOriginal, { wait: 500 })
|
||||
const [activeTemplateCategory] = useActiveTemplateCategory()
|
||||
|
||||
// Template collections query (for non-search mode)
|
||||
const templateCollectionsQuery = useMarketplaceTemplateCollectionsAndTemplates(undefined, { enabled })
|
||||
|
||||
// Sort value
|
||||
const sort = useMarketplaceSortValue()
|
||||
|
||||
// Search mode: when there's search text or non-default category
|
||||
const isSearchMode = useMarketplaceSearchMode()
|
||||
|
||||
// Build query params for search mode
|
||||
const queryParams = useMemo((): TemplateSearchParams | undefined => {
|
||||
if (!isSearchMode)
|
||||
return undefined
|
||||
return {
|
||||
query: searchText,
|
||||
categories: activeTemplateCategory === CATEGORY_ALL ? undefined : [activeTemplateCategory],
|
||||
sort_by: sort.sortBy,
|
||||
sort_order: sort.sortOrder,
|
||||
}
|
||||
}, [isSearchMode, searchText, activeTemplateCategory, sort])
|
||||
|
||||
// Templates search query (for search mode)
|
||||
const templatesQuery = useMarketplaceTemplates(queryParams, { enabled })
|
||||
const { hasNextPage, fetchNextPage, isFetching, isFetchingNextPage } = templatesQuery
|
||||
|
||||
// Pagination handler
|
||||
const handlePageChange = useCallback(() => {
|
||||
if (hasNextPage && !isFetching)
|
||||
fetchNextPage()
|
||||
}, [fetchNextPage, hasNextPage, isFetching])
|
||||
|
||||
// Scroll pagination
|
||||
useMarketplaceContainerScroll(handlePageChange)
|
||||
|
||||
return {
|
||||
templateCollections: templateCollectionsQuery.data?.templateCollections,
|
||||
templateCollectionTemplatesMap: templateCollectionsQuery.data?.templateCollectionTemplatesMap,
|
||||
templates: templatesQuery.data?.pages.flatMap(page => page.templates).map(mapTemplateDetailToTemplate),
|
||||
templatesTotal: templatesQuery.data?.pages[0]?.total,
|
||||
page: templatesQuery.data?.pages.length || 1,
|
||||
isLoading: templateCollectionsQuery.isLoading || templatesQuery.isLoading,
|
||||
isFetchingNextPage,
|
||||
}
|
||||
}
|
||||
|
||||
export type PluginsMarketplaceData = ReturnType<typeof usePluginsMarketplaceData>
|
||||
export type TemplatesMarketplaceData = ReturnType<typeof useTemplatesMarketplaceData>
|
||||
export type MarketplaceData = PluginsMarketplaceData | TemplatesMarketplaceData
|
||||
|
||||
export function isPluginsData(data: MarketplaceData): data is PluginsMarketplaceData {
|
||||
return 'pluginCollections' in data
|
||||
}
|
||||
|
||||
/**
|
||||
* Main hook that routes to appropriate data based on creationType
|
||||
* Returns either plugins or templates data based on URL parameter
|
||||
*/
|
||||
export function useMarketplaceData(): MarketplaceData {
|
||||
const [creationType] = useCreationType()
|
||||
|
||||
const pluginsData = usePluginsMarketplaceData(creationType === CREATION_TYPE.plugins)
|
||||
const templatesData = useTemplatesMarketplaceData(creationType === CREATION_TYPE.templates)
|
||||
return creationType === CREATION_TYPE.templates ? templatesData : pluginsData
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/utils/classnames'
|
||||
import PluginTypeSwitch from './plugin-type-switch'
|
||||
import SearchBoxWrapper from './search-box/search-box-wrapper'
|
||||
|
||||
type StickySearchAndSwitchWrapperProps = {
|
||||
pluginTypeSwitchClassName?: string
|
||||
}
|
||||
|
||||
const StickySearchAndSwitchWrapper = ({
|
||||
pluginTypeSwitchClassName,
|
||||
}: StickySearchAndSwitchWrapperProps) => {
|
||||
const hasCustomTopClass = pluginTypeSwitchClassName?.includes('top-')
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-4 bg-background-body',
|
||||
hasCustomTopClass && 'sticky z-10',
|
||||
pluginTypeSwitchClassName,
|
||||
)}
|
||||
>
|
||||
<SearchBoxWrapper />
|
||||
<PluginTypeSwitch />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StickySearchAndSwitchWrapper
|
||||
@@ -6,7 +6,7 @@ export type SearchParamsFromCollection = {
|
||||
sort_order?: string
|
||||
}
|
||||
|
||||
export type MarketplaceCollection = {
|
||||
export type PluginCollection = {
|
||||
name: string
|
||||
label: Record<string, string>
|
||||
description: Record<string, string>
|
||||
@@ -18,7 +18,7 @@ export type MarketplaceCollection = {
|
||||
}
|
||||
|
||||
export type MarketplaceCollectionsResponse = {
|
||||
collections: MarketplaceCollection[]
|
||||
collections: PluginCollection[]
|
||||
total: number
|
||||
}
|
||||
|
||||
@@ -56,4 +56,182 @@ export type SearchParams = {
|
||||
q?: string
|
||||
tags?: string
|
||||
category?: string
|
||||
creationType?: string
|
||||
}
|
||||
|
||||
export type TemplateCollection = {
|
||||
id: string
|
||||
name: string
|
||||
label: Record<string, string>
|
||||
description: Record<string, string>
|
||||
conditions: string[]
|
||||
searchable: boolean
|
||||
search_params?: SearchParamsFromCollection
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export type Template = {
|
||||
template_id: string
|
||||
name: string
|
||||
description: Record<string, string>
|
||||
icon: string
|
||||
icon_background?: string
|
||||
tags: string[]
|
||||
author: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type CreateTemplateCollectionRequest = {
|
||||
name: string
|
||||
description: Record<string, string>
|
||||
label: Record<string, string>
|
||||
conditions: string[]
|
||||
searchable: boolean
|
||||
search_params: SearchParamsFromCollection
|
||||
}
|
||||
|
||||
export type GetCollectionTemplatesRequest = {
|
||||
categories?: string[]
|
||||
exclude?: string[]
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export type AddTemplateToCollectionRequest = {
|
||||
template_id: string
|
||||
}
|
||||
|
||||
export type BatchAddTemplatesToCollectionRequest = {
|
||||
template_id: string
|
||||
}[]
|
||||
|
||||
// Creator types
|
||||
export type Creator = {
|
||||
email: string
|
||||
name: string
|
||||
display_name: string
|
||||
unique_handle: string
|
||||
display_email: string
|
||||
description: string
|
||||
avatar: string
|
||||
social_links: string[]
|
||||
status: 'active' | 'inactive'
|
||||
public?: boolean
|
||||
plugin_count?: number
|
||||
template_count?: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type CreatorSearchParams = {
|
||||
query?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
categories?: string[]
|
||||
sort_by?: string
|
||||
sort_order?: string
|
||||
}
|
||||
|
||||
export type CreatorSearchResponse = {
|
||||
creators: Creator[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export type SyncCreatorProfileRequest = {
|
||||
email: string
|
||||
name?: string
|
||||
display_name?: string
|
||||
unique_handle: string
|
||||
display_email?: string
|
||||
description?: string
|
||||
avatar?: string
|
||||
social_links?: string[]
|
||||
status?: 'active' | 'inactive'
|
||||
}
|
||||
|
||||
// Template Detail (full template info from API)
|
||||
export type TemplateDetail = {
|
||||
id: string
|
||||
publisher_type: 'individual' | 'organization'
|
||||
publisher_unique_handle: string
|
||||
creator_email: string
|
||||
template_name: string
|
||||
icon: string
|
||||
icon_background: string
|
||||
icon_file_key: string
|
||||
dsl_file_key: string
|
||||
categories: string[]
|
||||
overview: string
|
||||
readme: string
|
||||
partner_link: string
|
||||
status: 'published' | 'draft' | 'pending' | 'rejected'
|
||||
review_comment: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type TemplatesListResponse = {
|
||||
templates: TemplateDetail[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export type TemplateSearchParams = {
|
||||
query?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
categories?: string[]
|
||||
sort_by?: string
|
||||
sort_order?: string
|
||||
languages?: string[]
|
||||
}
|
||||
|
||||
// Unified search types
|
||||
|
||||
export type UnifiedSearchScope = 'creators' | 'organizations' | 'plugins' | 'templates'
|
||||
|
||||
export type UnifiedSearchParams = {
|
||||
query: string
|
||||
scope?: UnifiedSearchScope[]
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
// Plugin item shape from /search/unified (superset of Plugin with index_id)
|
||||
export type UnifiedPluginItem = Plugin & {
|
||||
index_id: string
|
||||
}
|
||||
|
||||
// Template item shape from /search/unified (differs from TemplateDetail)
|
||||
export type UnifiedTemplateItem = {
|
||||
id: string
|
||||
index_id: string
|
||||
template_name: string
|
||||
icon: string
|
||||
icon_background?: string
|
||||
icon_file_key: string
|
||||
categories: string[]
|
||||
overview: string
|
||||
readme: string
|
||||
partner_link: string
|
||||
publisher_handle: string
|
||||
publisher_type: 'individual' | 'organization'
|
||||
status: string
|
||||
usage_count: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// Creator item shape from /search/unified (superset of Creator with index_id)
|
||||
export type UnifiedCreatorItem = Creator & {
|
||||
index_id: string
|
||||
}
|
||||
|
||||
export type UnifiedSearchResponse = {
|
||||
data: {
|
||||
creators: { items: UnifiedCreatorItem[], total: number }
|
||||
organizations: { items: unknown[], total: number }
|
||||
plugins: { items: UnifiedPluginItem[], total: number }
|
||||
templates: { items: UnifiedTemplateItem[], total: number }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import type { ActivePluginType } from './constants'
|
||||
import type {
|
||||
CollectionsAndPluginsSearchParams,
|
||||
MarketplaceCollection,
|
||||
Creator,
|
||||
CreatorSearchParams,
|
||||
PluginCollection,
|
||||
PluginsSearchParams,
|
||||
Template,
|
||||
TemplateCollection,
|
||||
TemplateDetail,
|
||||
TemplateSearchParams,
|
||||
UnifiedCreatorItem,
|
||||
UnifiedPluginItem,
|
||||
UnifiedSearchParams,
|
||||
UnifiedSearchResponse,
|
||||
UnifiedTemplateItem,
|
||||
} from '@/app/components/plugins/marketplace/types'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
@@ -17,12 +28,21 @@ type MarketplaceFetchOptions = {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/** Get a string key from an item by field name (e.g. plugin_id, template_id). */
|
||||
export function getItemKeyByField<T>(item: T, field: keyof T): string {
|
||||
return String((item as Record<string, unknown>)[field as string])
|
||||
}
|
||||
|
||||
export const getPluginIconInMarketplace = (plugin: Plugin) => {
|
||||
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 getCreatorAvatarUrl = (uniqueHandle: string) => {
|
||||
return `${MARKETPLACE_API_PREFIX}/creators/${uniqueHandle}/avatar`
|
||||
}
|
||||
|
||||
export const getFormattedPlugin = (bundle: Plugin): Plugin => {
|
||||
if (bundle.type === 'bundle') {
|
||||
return {
|
||||
@@ -59,7 +79,7 @@ export const getMarketplacePluginsByCollectionId = async (
|
||||
let plugins: Plugin[] = []
|
||||
|
||||
try {
|
||||
const marketplaceCollectionPluginsDataJson = await marketplaceClient.collectionPlugins({
|
||||
const marketplaceCollectionPluginsDataJson = await marketplaceClient.plugins.collectionPlugins({
|
||||
params: {
|
||||
collectionId,
|
||||
},
|
||||
@@ -81,10 +101,10 @@ export const getMarketplaceCollectionsAndPlugins = async (
|
||||
query?: CollectionsAndPluginsSearchParams,
|
||||
options?: MarketplaceFetchOptions,
|
||||
) => {
|
||||
let marketplaceCollections: MarketplaceCollection[] = []
|
||||
let marketplaceCollectionPluginsMap: Record<string, Plugin[]> = {}
|
||||
let pluginCollections: PluginCollection[] = []
|
||||
let pluginCollectionPluginsMap: Record<string, Plugin[]> = {}
|
||||
try {
|
||||
const marketplaceCollectionsDataJson = await marketplaceClient.collections({
|
||||
const collectionsDataJson = await marketplaceClient.plugins.collections({
|
||||
query: {
|
||||
...query,
|
||||
page: 1,
|
||||
@@ -93,22 +113,84 @@ export const getMarketplaceCollectionsAndPlugins = async (
|
||||
}, {
|
||||
signal: options?.signal,
|
||||
})
|
||||
marketplaceCollections = marketplaceCollectionsDataJson.data?.collections || []
|
||||
await Promise.all(marketplaceCollections.map(async (collection: MarketplaceCollection) => {
|
||||
pluginCollections = collectionsDataJson.data?.collections || []
|
||||
await Promise.all(pluginCollections.map(async (collection: PluginCollection) => {
|
||||
const plugins = await getMarketplacePluginsByCollectionId(collection.name, query, options)
|
||||
|
||||
marketplaceCollectionPluginsMap[collection.name] = plugins
|
||||
pluginCollectionPluginsMap[collection.name] = plugins
|
||||
}))
|
||||
}
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
catch (e) {
|
||||
marketplaceCollections = []
|
||||
marketplaceCollectionPluginsMap = {}
|
||||
pluginCollections = []
|
||||
pluginCollectionPluginsMap = {}
|
||||
}
|
||||
|
||||
return {
|
||||
marketplaceCollections,
|
||||
marketplaceCollectionPluginsMap,
|
||||
marketplaceCollections: pluginCollections,
|
||||
marketplaceCollectionPluginsMap: pluginCollectionPluginsMap,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapTemplateDetailToTemplate(template: TemplateDetail): Template {
|
||||
const descriptionText = template.overview || template.readme || ''
|
||||
return {
|
||||
template_id: template.id,
|
||||
name: template.template_name,
|
||||
description: {
|
||||
en_US: descriptionText,
|
||||
zh_Hans: descriptionText,
|
||||
},
|
||||
icon: template.icon || '',
|
||||
icon_background: template.icon_background || undefined,
|
||||
tags: template.categories || [],
|
||||
author: template.publisher_unique_handle || template.creator_email || '',
|
||||
created_at: template.created_at,
|
||||
updated_at: template.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
export const getMarketplaceTemplateCollectionsAndTemplates = async (
|
||||
query?: { page?: number, page_size?: number, condition?: string },
|
||||
options?: MarketplaceFetchOptions,
|
||||
) => {
|
||||
let templateCollections: TemplateCollection[] = []
|
||||
let templateCollectionTemplatesMap: Record<string, Template[]> = {}
|
||||
|
||||
try {
|
||||
const res = await marketplaceClient.templateCollections.list({
|
||||
query: {
|
||||
...query,
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
},
|
||||
}, {
|
||||
signal: options?.signal,
|
||||
})
|
||||
templateCollections = res.data?.collections || []
|
||||
|
||||
await Promise.all(templateCollections.map(async (collection) => {
|
||||
try {
|
||||
const templatesRes = await marketplaceClient.templateCollections.getTemplates({
|
||||
params: { collectionName: collection.name },
|
||||
body: { limit: 20 },
|
||||
}, { signal: options?.signal })
|
||||
const templatesData = templatesRes.data?.templates || []
|
||||
templateCollectionTemplatesMap[collection.name] = templatesData.map(mapTemplateDetailToTemplate)
|
||||
}
|
||||
catch {
|
||||
templateCollectionTemplatesMap[collection.name] = []
|
||||
}
|
||||
}))
|
||||
}
|
||||
catch {
|
||||
templateCollections = []
|
||||
templateCollectionTemplatesMap = {}
|
||||
}
|
||||
|
||||
return {
|
||||
templateCollections,
|
||||
templateCollectionTemplatesMap,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +219,7 @@ export const getMarketplacePlugins = async (
|
||||
} = queryParams
|
||||
|
||||
try {
|
||||
const res = await marketplaceClient.searchAdvanced({
|
||||
const res = await marketplaceClient.plugins.searchAdvanced({
|
||||
params: {
|
||||
kind: type === 'bundle' ? 'bundles' : 'plugins',
|
||||
},
|
||||
@@ -170,7 +252,7 @@ export const getMarketplacePlugins = async (
|
||||
}
|
||||
}
|
||||
|
||||
export const getMarketplaceListCondition = (pluginType: string) => {
|
||||
export const getPluginCondition = (pluginType: string) => {
|
||||
if ([PluginCategoryEnum.tool, PluginCategoryEnum.agent, PluginCategoryEnum.model, PluginCategoryEnum.datasource, PluginCategoryEnum.trigger].includes(pluginType as PluginCategoryEnum))
|
||||
return `category=${pluginType}`
|
||||
|
||||
@@ -183,7 +265,7 @@ export const getMarketplaceListCondition = (pluginType: string) => {
|
||||
return ''
|
||||
}
|
||||
|
||||
export const getMarketplaceListFilterType = (category: ActivePluginType) => {
|
||||
export const getPluginFilterType = (category: ActivePluginType) => {
|
||||
if (category === PLUGIN_TYPE_SEARCH_MAP.all)
|
||||
return undefined
|
||||
|
||||
@@ -199,7 +281,255 @@ export function getCollectionsParams(category: ActivePluginType): CollectionsAnd
|
||||
}
|
||||
return {
|
||||
category,
|
||||
condition: getMarketplaceListCondition(category),
|
||||
type: getMarketplaceListFilterType(category),
|
||||
condition: getPluginCondition(category),
|
||||
type: getPluginFilterType(category),
|
||||
}
|
||||
}
|
||||
|
||||
export const getMarketplaceTemplates = async (
|
||||
queryParams: TemplateSearchParams | undefined,
|
||||
pageParam: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{
|
||||
templates: TemplateDetail[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}> => {
|
||||
if (!queryParams) {
|
||||
return {
|
||||
templates: [] as TemplateDetail[],
|
||||
total: 0,
|
||||
page: 1,
|
||||
page_size: 40,
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
query,
|
||||
sort_by,
|
||||
sort_order,
|
||||
categories,
|
||||
languages,
|
||||
page_size = 40,
|
||||
} = queryParams
|
||||
|
||||
try {
|
||||
const res = await marketplaceClient.templates.searchAdvanced({
|
||||
body: {
|
||||
page: pageParam,
|
||||
page_size,
|
||||
query,
|
||||
sort_by,
|
||||
sort_order,
|
||||
categories,
|
||||
languages,
|
||||
},
|
||||
}, { signal })
|
||||
|
||||
return {
|
||||
templates: res.data?.templates || [],
|
||||
total: res.data?.total || 0,
|
||||
page: pageParam,
|
||||
page_size,
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return {
|
||||
templates: [],
|
||||
total: 0,
|
||||
page: pageParam,
|
||||
page_size,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const getMarketplaceCreators = async (
|
||||
queryParams: CreatorSearchParams | undefined,
|
||||
pageParam: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{
|
||||
creators: Creator[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}> => {
|
||||
if (!queryParams) {
|
||||
return {
|
||||
creators: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
page_size: 40,
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
query,
|
||||
sort_by,
|
||||
sort_order,
|
||||
categories,
|
||||
page_size = 40,
|
||||
} = queryParams
|
||||
|
||||
try {
|
||||
const res = await marketplaceClient.creators.searchAdvanced({
|
||||
body: {
|
||||
page: pageParam,
|
||||
page_size,
|
||||
query,
|
||||
sort_by,
|
||||
sort_order,
|
||||
categories,
|
||||
},
|
||||
}, { signal })
|
||||
|
||||
const creators = (res.data?.creators || []).map((c: Creator) => ({
|
||||
...c,
|
||||
display_name: c.display_name || c.name,
|
||||
display_email: c.display_email ?? '',
|
||||
social_links: c.social_links ?? [],
|
||||
}))
|
||||
|
||||
return {
|
||||
creators,
|
||||
total: res.data?.total || 0,
|
||||
page: pageParam,
|
||||
page_size,
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return {
|
||||
creators: [],
|
||||
total: 0,
|
||||
page: pageParam,
|
||||
page_size,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map unified search plugin item to Plugin type
|
||||
*/
|
||||
export function mapUnifiedPluginToPlugin(item: UnifiedPluginItem): Plugin {
|
||||
return {
|
||||
type: item.type,
|
||||
org: item.org,
|
||||
name: item.name,
|
||||
plugin_id: item.plugin_id,
|
||||
version: item.latest_version,
|
||||
latest_version: item.latest_version,
|
||||
latest_package_identifier: item.latest_package_identifier,
|
||||
icon: `${MARKETPLACE_API_PREFIX}/plugins/${item.org}/${item.name}/icon`,
|
||||
verified: item.verification?.authorized_category === 'langgenius',
|
||||
label: item.label,
|
||||
brief: item.brief,
|
||||
description: item.brief,
|
||||
introduction: '',
|
||||
repository: item.repository || '',
|
||||
category: item.category as PluginCategoryEnum,
|
||||
install_count: item.install_count,
|
||||
endpoint: { settings: [] },
|
||||
tags: item.tags || [],
|
||||
badges: item.badges || [],
|
||||
verification: item.verification,
|
||||
from: 'marketplace',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map unified search template item to Template type
|
||||
*/
|
||||
export function mapUnifiedTemplateToTemplate(item: UnifiedTemplateItem): Template {
|
||||
const descriptionText = item.overview || item.readme || ''
|
||||
return {
|
||||
template_id: item.id,
|
||||
name: item.template_name,
|
||||
description: {
|
||||
en_US: descriptionText,
|
||||
zh_Hans: descriptionText,
|
||||
},
|
||||
icon: item.icon || '',
|
||||
icon_background: item.icon_background || undefined,
|
||||
tags: item.categories || [],
|
||||
author: item.publisher_handle || '',
|
||||
created_at: item.created_at,
|
||||
updated_at: item.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map unified search creator item to Creator type
|
||||
*/
|
||||
export function mapUnifiedCreatorToCreator(item: UnifiedCreatorItem): Creator {
|
||||
return {
|
||||
email: item.email || '',
|
||||
name: item.name || '',
|
||||
display_name: item.display_name || item.name || '',
|
||||
unique_handle: item.unique_handle || '',
|
||||
display_email: '',
|
||||
description: item.description || '',
|
||||
avatar: item.avatar || '',
|
||||
social_links: [],
|
||||
status: item.status || 'active',
|
||||
plugin_count: item.plugin_count,
|
||||
template_count: item.template_count,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch unified search results
|
||||
*/
|
||||
export const getMarketplaceUnifiedSearch = async (
|
||||
queryParams: UnifiedSearchParams | undefined,
|
||||
signal?: AbortSignal,
|
||||
): Promise<UnifiedSearchResponse['data'] & { page: number, page_size: number }> => {
|
||||
if (!queryParams || !queryParams.query.trim()) {
|
||||
return {
|
||||
creators: { items: [], total: 0 },
|
||||
organizations: { items: [], total: 0 },
|
||||
plugins: { items: [], total: 0 },
|
||||
templates: { items: [], total: 0 },
|
||||
page: 1,
|
||||
page_size: queryParams?.page_size || 10,
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
query,
|
||||
scope,
|
||||
page = 1,
|
||||
page_size = 10,
|
||||
} = queryParams
|
||||
|
||||
try {
|
||||
const res = await marketplaceClient.searchUnified({
|
||||
body: {
|
||||
query,
|
||||
scope,
|
||||
page,
|
||||
page_size,
|
||||
},
|
||||
}, { signal })
|
||||
|
||||
return {
|
||||
creators: res.data?.creators || { items: [], total: 0 },
|
||||
organizations: res.data?.organizations || { items: [], total: 0 },
|
||||
plugins: res.data?.plugins || { items: [], total: 0 },
|
||||
templates: res.data?.templates || { items: [], total: 0 },
|
||||
page,
|
||||
page_size,
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return {
|
||||
creators: { items: [], total: 0 },
|
||||
organizations: { items: [], total: 0 },
|
||||
plugins: { items: [], total: 0 },
|
||||
templates: { items: [], total: 0 },
|
||||
page,
|
||||
page_size,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,11 @@
|
||||
|
||||
import type { Dependency, PluginDeclaration, PluginManifestInMarket } from '../types'
|
||||
import {
|
||||
RiBookOpenLine,
|
||||
RiDragDropLine,
|
||||
RiEqualizer2Line,
|
||||
} from '@remixicon/react'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
@@ -17,22 +15,22 @@ import Tooltip from '@/app/components/base/tooltip'
|
||||
import ReferenceSettingModal from '@/app/components/plugins/reference-setting-modal'
|
||||
import { MARKETPLACE_API_PREFIX, SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS } from '@/config'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { usePluginInstallation } from '@/hooks/use-query-params'
|
||||
import { fetchBundleInfoFromMarketPlace, fetchManifestFromMarketPlace } from '@/service/plugins'
|
||||
import { sleep } from '@/utils'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { PLUGIN_PAGE_TABS_MAP } from '../hooks'
|
||||
import InstallFromLocalPackage from '../install-plugin/install-from-local-package'
|
||||
import InstallFromMarketplace from '../install-plugin/install-from-marketplace'
|
||||
import { PLUGIN_TYPE_SEARCH_MAP } from '../marketplace/constants'
|
||||
import SearchBoxWrapper from '../marketplace/search-box/search-box-wrapper'
|
||||
import {
|
||||
PluginPageContextProvider,
|
||||
usePluginPageContext,
|
||||
} from './context'
|
||||
import DebugInfo from './debug-info'
|
||||
import InstallPluginDropdown from './install-plugin-dropdown'
|
||||
import { SubmitRequestDropdown } from './nav-operations'
|
||||
import PluginTasks from './plugin-tasks'
|
||||
import useReferenceSetting from './use-reference-setting'
|
||||
import { useUploader } from './use-uploader'
|
||||
@@ -46,7 +44,6 @@ const PluginPage = ({
|
||||
marketplace,
|
||||
}: PluginPageProps) => {
|
||||
const { t } = useTranslation()
|
||||
const docLink = useDocLink()
|
||||
useDocumentTitle(t('metadata.title', { ns: 'plugin' }))
|
||||
|
||||
// Use nuqs hook for installation state
|
||||
@@ -140,55 +137,20 @@ const PluginPage = ({
|
||||
id="marketplace-container"
|
||||
ref={containerRef}
|
||||
style={{ scrollbarGutter: 'stable' }}
|
||||
className={cn('relative flex grow flex-col overflow-y-auto border-t border-divider-subtle', isPluginsTab
|
||||
? 'rounded-t-xl bg-components-panel-bg'
|
||||
: 'bg-background-body')}
|
||||
className="relative flex grow flex-col overflow-y-auto rounded-t-xl border-t border-divider-subtle bg-components-panel-bg"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'sticky top-0 z-10 flex min-h-[60px] items-center gap-1 self-stretch bg-components-panel-bg px-12 pb-2 pt-4',
|
||||
isExploringMarketplace && 'bg-background-body',
|
||||
)}
|
||||
>
|
||||
<div className="sticky top-0 z-10 flex min-h-[60px] items-center gap-1 self-stretch bg-components-panel-bg px-12 pb-2 pt-4">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-1 items-center justify-start gap-2">
|
||||
<TabSlider
|
||||
value={isPluginsTab ? PLUGIN_PAGE_TABS_MAP.plugins : PLUGIN_PAGE_TABS_MAP.marketplace}
|
||||
onChange={setActiveTab}
|
||||
options={options}
|
||||
/>
|
||||
{!isPluginsTab && <SearchBoxWrapper />}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{
|
||||
isExploringMarketplace && (
|
||||
<>
|
||||
<Link
|
||||
href="https://github.com/langgenius/dify-plugins/issues/new?template=plugin_request.yaml"
|
||||
target="_blank"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-text-tertiary"
|
||||
>
|
||||
{t('requestAPlugin', { ns: 'plugin' })}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link
|
||||
href={docLink('/develop-plugin/publishing/marketplace-listing/release-to-dify-marketplace')}
|
||||
target="_blank"
|
||||
>
|
||||
<Button
|
||||
className="px-3"
|
||||
variant="secondary-accent"
|
||||
>
|
||||
<RiBookOpenLine className="mr-1 h-4 w-4" />
|
||||
{t('publishPlugins', { ns: 'plugin' })}
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="mx-1 h-3.5 w-[1px] shrink-0 bg-divider-regular"></div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{isExploringMarketplace && <SubmitRequestDropdown />}
|
||||
<PluginTasks />
|
||||
{canManagement && (
|
||||
<InstallPluginDropdown
|
||||
|
||||
121
web/app/components/plugins/plugin-page/nav-operations.tsx
Normal file
121
web/app/components/plugins/plugin-page/nav-operations.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
'use client'
|
||||
import { RiAddLine, RiBookOpenLine, RiGithubLine } from '@remixicon/react'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import Button, { buttonVariants } from '@/app/components/base/button'
|
||||
import { Playground, Plugin } from '@/app/components/base/icons/src/vender/plugin'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { CREATION_TYPE } from '@/app/components/plugins/marketplace/search-params'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useCreationType } from '../marketplace/atoms'
|
||||
|
||||
type DropdownItemProps = {
|
||||
href: string
|
||||
icon: React.ReactNode
|
||||
text: string
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
const DropdownItem = ({ href, icon, text, onClick }: DropdownItemProps) => (
|
||||
<Link
|
||||
href={href}
|
||||
target="_blank"
|
||||
className="flex items-center gap-2 rounded-lg px-3 py-2 text-text-secondary hover:bg-state-base-hover hover:text-text-primary"
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon}
|
||||
<span className="system-sm-medium">{text}</span>
|
||||
</Link>
|
||||
)
|
||||
|
||||
export const SubmitRequestDropdown = () => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const docLink = useDocLink()
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement="bottom-start"
|
||||
offset={4}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-3 py-2 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
|
||||
open && 'bg-state-base-hover text-text-secondary',
|
||||
)}
|
||||
>
|
||||
<RiAddLine className="h-4 w-4 shrink-0 lg:hidden" />
|
||||
<span className="system-sm-medium hidden lg:inline">
|
||||
{t('requestSubmitPlugin', { ns: 'plugin' })}
|
||||
</span>
|
||||
</Button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[1000]">
|
||||
<div className="min-w-[200px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-sm">
|
||||
<DropdownItem
|
||||
href="https://github.com/langgenius/dify-plugins/issues/new?template=plugin_request.yaml"
|
||||
icon={<RiGithubLine className="h-4 w-4 shrink-0" />}
|
||||
text={t('requestAPlugin', { ns: 'plugin' })}
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
<DropdownItem
|
||||
href={docLink('/develop-plugin/publishing/marketplace-listing/release-to-dify-marketplace')}
|
||||
icon={<RiBookOpenLine className="h-4 w-4 shrink-0" />}
|
||||
text={t('publishPlugins', { ns: 'plugin' })}
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export const CreationTypeTabs = () => {
|
||||
const { t } = useTranslation()
|
||||
const [creationType] = useCreationType()
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Link
|
||||
href={`/?creationType=${CREATION_TYPE.plugins}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
'flex items-center gap-1 px-3 py-2 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
|
||||
creationType === CREATION_TYPE.plugins && 'bg-state-base-hover text-text-secondary',
|
||||
)}
|
||||
>
|
||||
<Plugin className="h-4 w-4 shrink-0" />
|
||||
<span className="system-sm-medium hidden md:inline">
|
||||
{t('plugins', { ns: 'plugin' })}
|
||||
</span>
|
||||
</Link>
|
||||
<Link
|
||||
href={`/?creationType=${CREATION_TYPE.templates}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
'flex items-center gap-1 px-3 py-2 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
|
||||
creationType === CREATION_TYPE.templates && 'bg-state-base-hover text-text-secondary',
|
||||
)}
|
||||
>
|
||||
<Playground className="h-4 w-4 shrink-0" />
|
||||
<span className="system-sm-medium hidden md:inline">
|
||||
{t('templates', { ns: 'plugin' })}
|
||||
</span>
|
||||
<Badge className="ml-1 hidden h-4 rounded-[4px] border-none bg-saas-dify-blue-accessible px-1 text-[10px] font-bold leading-[14px] text-text-primary-on-surface md:inline-flex">
|
||||
NEW
|
||||
</Badge>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,11 +9,11 @@ import {
|
||||
useMarketplaceCollectionsAndPlugins,
|
||||
useMarketplacePlugins,
|
||||
} from '@/app/components/plugins/marketplace/hooks'
|
||||
import { getMarketplaceListCondition } from '@/app/components/plugins/marketplace/utils'
|
||||
import { getPluginCondition } from '@/app/components/plugins/marketplace/utils'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import { useAllToolProviders } from '@/service/use-tools'
|
||||
|
||||
export const useMarketplace = (searchPluginText: string, filterPluginTags: string[]) => {
|
||||
export const useMarketplace = (searchText: string, filterPluginTags: string[]) => {
|
||||
const { data: toolProvidersData, isSuccess } = useAllToolProviders()
|
||||
const exclude = useMemo(() => {
|
||||
if (isSuccess)
|
||||
@@ -21,8 +21,8 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin
|
||||
}, [isSuccess, toolProvidersData])
|
||||
const {
|
||||
isLoading,
|
||||
marketplaceCollections,
|
||||
marketplaceCollectionPluginsMap,
|
||||
pluginCollections,
|
||||
pluginCollectionPluginsMap,
|
||||
queryMarketplaceCollectionsAndPlugins,
|
||||
} = useMarketplaceCollectionsAndPlugins()
|
||||
const {
|
||||
@@ -35,19 +35,19 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin
|
||||
hasNextPage,
|
||||
page: pluginsPage,
|
||||
} = useMarketplacePlugins()
|
||||
const searchPluginTextRef = useRef(searchPluginText)
|
||||
const searchTextRef = useRef(searchText)
|
||||
const filterPluginTagsRef = useRef(filterPluginTags)
|
||||
|
||||
useEffect(() => {
|
||||
searchPluginTextRef.current = searchPluginText
|
||||
searchTextRef.current = searchText
|
||||
filterPluginTagsRef.current = filterPluginTags
|
||||
}, [searchPluginText, filterPluginTags])
|
||||
}, [searchText, filterPluginTags])
|
||||
useEffect(() => {
|
||||
if ((searchPluginText || filterPluginTags.length) && isSuccess) {
|
||||
if (searchPluginText) {
|
||||
if ((searchText || filterPluginTags.length) && isSuccess) {
|
||||
if (searchText) {
|
||||
queryPluginsWithDebounced({
|
||||
category: PluginCategoryEnum.tool,
|
||||
query: searchPluginText,
|
||||
query: searchText,
|
||||
tags: filterPluginTags,
|
||||
exclude,
|
||||
type: 'plugin',
|
||||
@@ -56,7 +56,7 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin
|
||||
}
|
||||
queryPlugins({
|
||||
category: PluginCategoryEnum.tool,
|
||||
query: searchPluginText,
|
||||
query: searchText,
|
||||
tags: filterPluginTags,
|
||||
exclude,
|
||||
type: 'plugin',
|
||||
@@ -66,14 +66,14 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin
|
||||
if (isSuccess) {
|
||||
queryMarketplaceCollectionsAndPlugins({
|
||||
category: PluginCategoryEnum.tool,
|
||||
condition: getMarketplaceListCondition(PluginCategoryEnum.tool),
|
||||
condition: getPluginCondition(PluginCategoryEnum.tool),
|
||||
exclude,
|
||||
type: 'plugin',
|
||||
})
|
||||
resetPlugins()
|
||||
}
|
||||
}
|
||||
}, [searchPluginText, filterPluginTags, queryPlugins, queryMarketplaceCollectionsAndPlugins, queryPluginsWithDebounced, resetPlugins, exclude, isSuccess])
|
||||
}, [searchText, filterPluginTags, queryPlugins, queryMarketplaceCollectionsAndPlugins, queryPluginsWithDebounced, resetPlugins, exclude, isSuccess])
|
||||
|
||||
const handleScroll = useCallback((e: Event) => {
|
||||
const target = e.target as HTMLDivElement
|
||||
@@ -83,17 +83,17 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin
|
||||
clientHeight,
|
||||
} = target
|
||||
if (scrollTop + clientHeight >= scrollHeight - SCROLL_BOTTOM_THRESHOLD && scrollTop > 0) {
|
||||
const searchPluginText = searchPluginTextRef.current
|
||||
const searchText = searchTextRef.current
|
||||
const filterPluginTags = filterPluginTagsRef.current
|
||||
if (hasNextPage && (!!searchPluginText || !!filterPluginTags.length))
|
||||
if (hasNextPage && (!!searchText || !!filterPluginTags.length))
|
||||
fetchNextPage()
|
||||
}
|
||||
}, [exclude, fetchNextPage, hasNextPage, plugins, queryPlugins])
|
||||
|
||||
return {
|
||||
isLoading: isLoading || isPluginsLoading,
|
||||
marketplaceCollections,
|
||||
marketplaceCollectionPluginsMap,
|
||||
pluginCollections,
|
||||
pluginCollectionPluginsMap,
|
||||
plugins,
|
||||
handleScroll,
|
||||
page: Math.max(pluginsPage || 0, 1),
|
||||
|
||||
@@ -4,7 +4,7 @@ import { act, render, renderHook, screen, waitFor } from '@testing-library/react
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { SCROLL_BOTTOM_THRESHOLD } from '@/app/components/plugins/marketplace/constants'
|
||||
import { getMarketplaceListCondition } from '@/app/components/plugins/marketplace/utils'
|
||||
import { getPluginCondition } from '@/app/components/plugins/marketplace/utils'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
@@ -15,8 +15,8 @@ import Marketplace from './index'
|
||||
const listRenderSpy = vi.fn()
|
||||
vi.mock('@/app/components/plugins/marketplace/list', () => ({
|
||||
default: (props: {
|
||||
marketplaceCollections: unknown[]
|
||||
marketplaceCollectionPluginsMap: Record<string, unknown[]>
|
||||
pluginCollections: unknown[]
|
||||
pluginCollectionPluginsMap: Record<string, unknown[]>
|
||||
plugins?: unknown[]
|
||||
showInstallButton?: boolean
|
||||
}) => {
|
||||
@@ -90,8 +90,8 @@ const createPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
|
||||
|
||||
const createMarketplaceContext = (overrides: Partial<ReturnType<typeof useMarketplace>> = {}) => ({
|
||||
isLoading: false,
|
||||
marketplaceCollections: [],
|
||||
marketplaceCollectionPluginsMap: {},
|
||||
pluginCollections: [],
|
||||
pluginCollectionPluginsMap: {},
|
||||
plugins: [],
|
||||
handleScroll: vi.fn(),
|
||||
page: 1,
|
||||
@@ -110,7 +110,7 @@ describe('Marketplace', () => {
|
||||
const marketplaceContext = createMarketplaceContext({ isLoading: true, page: 1 })
|
||||
render(
|
||||
<Marketplace
|
||||
searchPluginText=""
|
||||
searchText=""
|
||||
filterPluginTags={[]}
|
||||
isMarketplaceArrowVisible={false}
|
||||
showMarketplacePanel={vi.fn()}
|
||||
@@ -131,7 +131,7 @@ describe('Marketplace', () => {
|
||||
})
|
||||
render(
|
||||
<Marketplace
|
||||
searchPluginText=""
|
||||
searchText=""
|
||||
filterPluginTags={[]}
|
||||
isMarketplaceArrowVisible={false}
|
||||
showMarketplacePanel={vi.fn()}
|
||||
@@ -156,7 +156,7 @@ describe('Marketplace', () => {
|
||||
const showMarketplacePanel = vi.fn()
|
||||
const { container } = render(
|
||||
<Marketplace
|
||||
searchPluginText="vector"
|
||||
searchText="vector"
|
||||
filterPluginTags={['tag-a', 'tag-b']}
|
||||
isMarketplaceArrowVisible
|
||||
showMarketplacePanel={showMarketplacePanel}
|
||||
@@ -199,8 +199,8 @@ describe('useMarketplace', () => {
|
||||
}) => {
|
||||
mockUseMarketplaceCollectionsAndPlugins.mockReturnValue({
|
||||
isLoading: overrides?.isLoading ?? false,
|
||||
marketplaceCollections: [],
|
||||
marketplaceCollectionPluginsMap: {},
|
||||
pluginCollections: [],
|
||||
pluginCollectionPluginsMap: {},
|
||||
queryMarketplaceCollectionsAndPlugins: mockQueryMarketplaceCollectionsAndPlugins,
|
||||
})
|
||||
mockUseMarketplacePlugins.mockReturnValue({
|
||||
@@ -289,7 +289,7 @@ describe('useMarketplace', () => {
|
||||
await waitFor(() => {
|
||||
expect(mockQueryMarketplaceCollectionsAndPlugins).toHaveBeenCalledWith({
|
||||
category: PluginCategoryEnum.tool,
|
||||
condition: getMarketplaceListCondition(PluginCategoryEnum.tool),
|
||||
condition: getPluginCondition(PluginCategoryEnum.tool),
|
||||
exclude: ['plugin-c'],
|
||||
type: 'plugin',
|
||||
})
|
||||
|
||||
@@ -11,14 +11,14 @@ import List from '@/app/components/plugins/marketplace/list'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
|
||||
type MarketplaceProps = {
|
||||
searchPluginText: string
|
||||
searchText: string
|
||||
filterPluginTags: string[]
|
||||
isMarketplaceArrowVisible: boolean
|
||||
showMarketplacePanel: () => void
|
||||
marketplaceContext: ReturnType<typeof useMarketplace>
|
||||
}
|
||||
const Marketplace = ({
|
||||
searchPluginText,
|
||||
searchText,
|
||||
filterPluginTags,
|
||||
isMarketplaceArrowVisible,
|
||||
showMarketplacePanel,
|
||||
@@ -29,8 +29,8 @@ const Marketplace = ({
|
||||
const { theme } = useTheme()
|
||||
const {
|
||||
isLoading,
|
||||
marketplaceCollections,
|
||||
marketplaceCollectionPluginsMap,
|
||||
pluginCollections,
|
||||
pluginCollectionPluginsMap,
|
||||
plugins,
|
||||
page,
|
||||
} = marketplaceContext
|
||||
@@ -79,7 +79,7 @@ const Marketplace = ({
|
||||
</span>
|
||||
{t('operation.in', { ns: 'common' })}
|
||||
<a
|
||||
href={getMarketplaceUrl('', { language: locale, q: searchPluginText, tags: filterPluginTags.join(','), theme })}
|
||||
href={getMarketplaceUrl('', { language: locale, q: searchText, tags: filterPluginTags.join(','), theme })}
|
||||
className="system-sm-medium ml-1 flex items-center text-text-accent"
|
||||
target="_blank"
|
||||
>
|
||||
@@ -100,8 +100,8 @@ const Marketplace = ({
|
||||
{
|
||||
(!isLoading || page > 1) && (
|
||||
<List
|
||||
marketplaceCollections={marketplaceCollections || []}
|
||||
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap || {}}
|
||||
pluginCollections={pluginCollections || []}
|
||||
pluginCollectionPluginsMap={pluginCollectionPluginsMap || {}}
|
||||
plugins={plugins}
|
||||
showInstallButton
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import TabSliderNew from '@/app/components/base/tab-slider-new'
|
||||
import Card from '@/app/components/plugins/card'
|
||||
import CardMoreInfo from '@/app/components/plugins/card/card-more-info'
|
||||
import CardTags from '@/app/components/plugins/card/card-tags'
|
||||
import { useTags } from '@/app/components/plugins/hooks'
|
||||
import Empty from '@/app/components/plugins/marketplace/empty'
|
||||
import PluginDetailPanel from '@/app/components/plugins/plugin-detail-panel'
|
||||
@@ -183,7 +183,7 @@ const ProviderList = () => {
|
||||
name: collection.plugin_id ? collection.plugin_id.split('/')[1] : collection.name,
|
||||
} as any}
|
||||
footer={(
|
||||
<CardMoreInfo
|
||||
<CardTags
|
||||
tags={collection.labels?.map(label => getTagLabel(label)) || []}
|
||||
/>
|
||||
)}
|
||||
@@ -199,7 +199,7 @@ const ProviderList = () => {
|
||||
<div ref={toolListTailRef} />
|
||||
{enable_marketplace && activeTab === 'builtin' && (
|
||||
<Marketplace
|
||||
searchPluginText={keywords}
|
||||
searchText={keywords}
|
||||
filterPluginTags={tagFilterValue}
|
||||
isMarketplaceArrowVisible={isMarketplaceArrowVisible}
|
||||
showMarketplacePanel={showMarketplacePanel}
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
import type { CollectionsAndPluginsSearchParams, MarketplaceCollection, PluginsSearchParams } from '@/app/components/plugins/marketplace/types'
|
||||
import type {
|
||||
AddTemplateToCollectionRequest,
|
||||
BatchAddTemplatesToCollectionRequest,
|
||||
CollectionsAndPluginsSearchParams,
|
||||
CreateTemplateCollectionRequest,
|
||||
Creator,
|
||||
CreatorSearchParams,
|
||||
CreatorSearchResponse,
|
||||
GetCollectionTemplatesRequest,
|
||||
PluginCollection,
|
||||
PluginsSearchParams,
|
||||
SyncCreatorProfileRequest,
|
||||
TemplateCollection,
|
||||
TemplateDetail,
|
||||
TemplateSearchParams,
|
||||
TemplatesListResponse,
|
||||
UnifiedSearchParams,
|
||||
UnifiedSearchResponse,
|
||||
} from '@/app/components/plugins/marketplace/types'
|
||||
import type { Plugin, PluginsFromMarketplaceResponse } from '@/app/components/plugins/types'
|
||||
import { type } from '@orpc/contract'
|
||||
import { base } from './base'
|
||||
|
||||
export const collectionsContract = base
|
||||
export const pluginCollectionsContract = base
|
||||
.route({
|
||||
path: '/collections',
|
||||
method: 'GET',
|
||||
@@ -16,7 +34,7 @@ export const collectionsContract = base
|
||||
.output(
|
||||
type<{
|
||||
data?: {
|
||||
collections?: MarketplaceCollection[]
|
||||
collections?: PluginCollection[]
|
||||
}
|
||||
}>(),
|
||||
)
|
||||
@@ -54,3 +72,356 @@ export const searchAdvancedContract = base
|
||||
body: Omit<PluginsSearchParams, 'type'>
|
||||
}>())
|
||||
.output(type<{ data: PluginsFromMarketplaceResponse }>())
|
||||
|
||||
export const templateCollectionsContract = base
|
||||
.route({
|
||||
path: '/template-collections',
|
||||
method: 'GET',
|
||||
})
|
||||
.input(
|
||||
type<{
|
||||
query?: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
condition?: string
|
||||
}
|
||||
}>(),
|
||||
)
|
||||
.output(
|
||||
type<{
|
||||
data?: {
|
||||
collections?: TemplateCollection[]
|
||||
has_more?: boolean
|
||||
limit?: number
|
||||
page?: number
|
||||
total?: number
|
||||
}
|
||||
}>(),
|
||||
)
|
||||
|
||||
export const createTemplateCollectionContract = base
|
||||
.route({
|
||||
path: '/template-collections',
|
||||
method: 'POST',
|
||||
})
|
||||
.input(
|
||||
type<{
|
||||
body: CreateTemplateCollectionRequest
|
||||
}>(),
|
||||
)
|
||||
.output(type<TemplateCollection>())
|
||||
|
||||
export const getTemplateCollectionContract = base
|
||||
.route({
|
||||
path: '/template-collections/{collectionName}',
|
||||
method: 'GET',
|
||||
})
|
||||
.input(
|
||||
type<{
|
||||
params: {
|
||||
collectionName: string
|
||||
}
|
||||
}>(),
|
||||
)
|
||||
.output(type<TemplateCollection>())
|
||||
|
||||
export const deleteTemplateCollectionContract = base
|
||||
.route({
|
||||
path: '/template-collections/{collectionName}',
|
||||
method: 'DELETE',
|
||||
})
|
||||
.input(
|
||||
type<{
|
||||
params: {
|
||||
collectionName: string
|
||||
}
|
||||
}>(),
|
||||
)
|
||||
.output(type<void>())
|
||||
|
||||
export const getCollectionTemplatesContract = base
|
||||
.route({
|
||||
path: '/template-collections/{collectionName}/templates',
|
||||
method: 'POST',
|
||||
})
|
||||
.input(
|
||||
type<{
|
||||
params: {
|
||||
collectionName: string
|
||||
}
|
||||
body?: GetCollectionTemplatesRequest
|
||||
}>(),
|
||||
)
|
||||
.output(
|
||||
type<{
|
||||
data?: TemplatesListResponse
|
||||
}>(),
|
||||
)
|
||||
|
||||
export const addTemplateToCollectionContract = base
|
||||
.route({
|
||||
path: '/template-collections/{collectionName}/templates',
|
||||
method: 'PUT',
|
||||
})
|
||||
.input(
|
||||
type<{
|
||||
params: {
|
||||
collectionName: string
|
||||
}
|
||||
body: AddTemplateToCollectionRequest
|
||||
}>(),
|
||||
)
|
||||
.output(type<void>())
|
||||
|
||||
export const batchAddTemplatesToCollectionContract = base
|
||||
.route({
|
||||
path: '/template-collections/{collectionName}/templates/batch-add',
|
||||
method: 'POST',
|
||||
})
|
||||
.input(
|
||||
type<{
|
||||
params: {
|
||||
collectionName: string
|
||||
}
|
||||
body: BatchAddTemplatesToCollectionRequest
|
||||
}>(),
|
||||
)
|
||||
.output(type<void>())
|
||||
|
||||
export const clearCollectionTemplatesContract = base
|
||||
.route({
|
||||
path: '/template-collections/{collectionName}/templates/clear',
|
||||
method: 'PUT',
|
||||
})
|
||||
.input(
|
||||
type<{
|
||||
params: {
|
||||
collectionName: string
|
||||
}
|
||||
}>(),
|
||||
)
|
||||
.output(type<void>())
|
||||
|
||||
// Creators contracts
|
||||
export const getCreatorByHandleContract = base
|
||||
.route({
|
||||
path: '/creators/{uniqueHandle}',
|
||||
method: 'GET',
|
||||
})
|
||||
.input(
|
||||
type<{
|
||||
params: {
|
||||
uniqueHandle: string
|
||||
}
|
||||
}>(),
|
||||
)
|
||||
.output(
|
||||
type<{
|
||||
data?: {
|
||||
creator?: Creator
|
||||
}
|
||||
}>(),
|
||||
)
|
||||
|
||||
export const getCreatorAvatarContract = base
|
||||
.route({
|
||||
path: '/creators/{uniqueHandle}/avatar',
|
||||
method: 'GET',
|
||||
})
|
||||
.input(
|
||||
type<{
|
||||
params: {
|
||||
uniqueHandle: string
|
||||
}
|
||||
}>(),
|
||||
)
|
||||
.output(type<Blob>())
|
||||
|
||||
export const syncCreatorProfileContract = base
|
||||
.route({
|
||||
path: '/creators/sync/profile',
|
||||
method: 'POST',
|
||||
})
|
||||
.input(
|
||||
type<{
|
||||
body: SyncCreatorProfileRequest
|
||||
}>(),
|
||||
)
|
||||
.output(
|
||||
type<{
|
||||
data?: {
|
||||
creator?: Creator
|
||||
}
|
||||
}>(),
|
||||
)
|
||||
|
||||
export const syncCreatorAvatarContract = base
|
||||
.route({
|
||||
path: '/creators/sync/avatar',
|
||||
method: 'POST',
|
||||
})
|
||||
.input(
|
||||
type<{
|
||||
body: FormData
|
||||
}>(),
|
||||
)
|
||||
.output(type<void>())
|
||||
|
||||
export const searchCreatorsAdvancedContract = base
|
||||
.route({
|
||||
path: '/creators/search/advanced',
|
||||
method: 'POST',
|
||||
})
|
||||
.input(
|
||||
type<{
|
||||
body: CreatorSearchParams
|
||||
}>(),
|
||||
)
|
||||
.output(
|
||||
type<{
|
||||
data?: CreatorSearchResponse
|
||||
}>(),
|
||||
)
|
||||
|
||||
// Templates public endpoints
|
||||
export const getTemplatesListContract = base
|
||||
.route({
|
||||
path: '/templates',
|
||||
method: 'GET',
|
||||
})
|
||||
.input(
|
||||
type<{
|
||||
query?: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
categories?: string
|
||||
}
|
||||
}>(),
|
||||
)
|
||||
.output(
|
||||
type<{
|
||||
data?: TemplatesListResponse
|
||||
}>(),
|
||||
)
|
||||
|
||||
export const getTemplateByIdContract = base
|
||||
.route({
|
||||
path: '/templates/{templateId}',
|
||||
method: 'GET',
|
||||
})
|
||||
.input(
|
||||
type<{
|
||||
params: {
|
||||
templateId: string
|
||||
}
|
||||
}>(),
|
||||
)
|
||||
.output(
|
||||
type<{
|
||||
data?: TemplateDetail
|
||||
}>(),
|
||||
)
|
||||
|
||||
export const getTemplateDslFileContract = base
|
||||
.route({
|
||||
path: '/templates/{templateId}/file',
|
||||
method: 'GET',
|
||||
})
|
||||
.input(
|
||||
type<{
|
||||
params: {
|
||||
templateId: string
|
||||
}
|
||||
}>(),
|
||||
)
|
||||
.output(type<Blob>())
|
||||
|
||||
export const searchTemplatesBasicContract = base
|
||||
.route({
|
||||
path: '/templates/search/basic',
|
||||
method: 'POST',
|
||||
})
|
||||
.input(
|
||||
type<{
|
||||
body: TemplateSearchParams
|
||||
}>(),
|
||||
)
|
||||
.output(
|
||||
type<{
|
||||
data?: TemplatesListResponse
|
||||
}>(),
|
||||
)
|
||||
|
||||
export const searchTemplatesAdvancedContract = base
|
||||
.route({
|
||||
path: '/templates/search/advanced',
|
||||
method: 'POST',
|
||||
})
|
||||
.input(
|
||||
type<{
|
||||
body: TemplateSearchParams
|
||||
}>(),
|
||||
)
|
||||
.output(
|
||||
type<{
|
||||
data?: TemplatesListResponse
|
||||
}>(),
|
||||
)
|
||||
|
||||
export const searchUnifiedContract = base
|
||||
.route({
|
||||
path: '/search/unified',
|
||||
method: 'POST',
|
||||
})
|
||||
.input(
|
||||
type<{
|
||||
body: UnifiedSearchParams
|
||||
}>(),
|
||||
)
|
||||
.output(
|
||||
type<UnifiedSearchResponse>(),
|
||||
)
|
||||
|
||||
export const getPublisherTemplatesContract = base
|
||||
.route({
|
||||
path: '/templates/publisher/{uniqueHandle}',
|
||||
method: 'GET',
|
||||
})
|
||||
.input(
|
||||
type<{
|
||||
params: {
|
||||
uniqueHandle: string
|
||||
}
|
||||
query?: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
}>(),
|
||||
)
|
||||
.output(
|
||||
type<{
|
||||
data?: TemplatesListResponse
|
||||
}>(),
|
||||
)
|
||||
|
||||
export const getPublisherPluginsContract = base
|
||||
.route({
|
||||
path: '/plugins/publisher/{uniqueHandle}',
|
||||
method: 'GET',
|
||||
})
|
||||
.input(
|
||||
type<{
|
||||
params: {
|
||||
uniqueHandle: string
|
||||
}
|
||||
query?: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
}>(),
|
||||
)
|
||||
.output(
|
||||
type<{
|
||||
data?: PluginsFromMarketplaceResponse
|
||||
}>(),
|
||||
)
|
||||
|
||||
@@ -19,12 +19,66 @@ import {
|
||||
triggerSubscriptionVerifyContract,
|
||||
} from './console/trigger'
|
||||
import { trialAppDatasetsContract, trialAppInfoContract, trialAppParametersContract, trialAppWorkflowsContract } from './console/try-app'
|
||||
import { collectionPluginsContract, collectionsContract, searchAdvancedContract } from './marketplace'
|
||||
import {
|
||||
addTemplateToCollectionContract,
|
||||
batchAddTemplatesToCollectionContract,
|
||||
clearCollectionTemplatesContract,
|
||||
collectionPluginsContract,
|
||||
createTemplateCollectionContract,
|
||||
deleteTemplateCollectionContract,
|
||||
getCollectionTemplatesContract,
|
||||
getCreatorAvatarContract,
|
||||
getCreatorByHandleContract,
|
||||
getPublisherPluginsContract,
|
||||
getPublisherTemplatesContract,
|
||||
getTemplateByIdContract,
|
||||
getTemplateCollectionContract,
|
||||
getTemplateDslFileContract,
|
||||
getTemplatesListContract,
|
||||
pluginCollectionsContract,
|
||||
searchAdvancedContract,
|
||||
searchCreatorsAdvancedContract,
|
||||
searchTemplatesAdvancedContract,
|
||||
searchTemplatesBasicContract,
|
||||
searchUnifiedContract,
|
||||
syncCreatorAvatarContract,
|
||||
syncCreatorProfileContract,
|
||||
templateCollectionsContract,
|
||||
} from './marketplace'
|
||||
|
||||
export const marketplaceRouterContract = {
|
||||
collections: collectionsContract,
|
||||
collectionPlugins: collectionPluginsContract,
|
||||
searchAdvanced: searchAdvancedContract,
|
||||
plugins: {
|
||||
collections: pluginCollectionsContract,
|
||||
collectionPlugins: collectionPluginsContract,
|
||||
searchAdvanced: searchAdvancedContract,
|
||||
getPublisherPlugins: getPublisherPluginsContract,
|
||||
},
|
||||
searchUnified: searchUnifiedContract,
|
||||
templateCollections: {
|
||||
list: templateCollectionsContract,
|
||||
create: createTemplateCollectionContract,
|
||||
get: getTemplateCollectionContract,
|
||||
delete: deleteTemplateCollectionContract,
|
||||
getTemplates: getCollectionTemplatesContract,
|
||||
addTemplate: addTemplateToCollectionContract,
|
||||
batchAddTemplates: batchAddTemplatesToCollectionContract,
|
||||
clearTemplates: clearCollectionTemplatesContract,
|
||||
},
|
||||
creators: {
|
||||
getByHandle: getCreatorByHandleContract,
|
||||
getAvatar: getCreatorAvatarContract,
|
||||
syncProfile: syncCreatorProfileContract,
|
||||
syncAvatar: syncCreatorAvatarContract,
|
||||
searchAdvanced: searchCreatorsAdvancedContract,
|
||||
},
|
||||
templates: {
|
||||
list: getTemplatesListContract,
|
||||
getById: getTemplateByIdContract,
|
||||
getDslFile: getTemplateDslFileContract,
|
||||
searchBasic: searchTemplatesBasicContract,
|
||||
searchAdvanced: searchTemplatesAdvancedContract,
|
||||
getPublisherTemplates: getPublisherTemplatesContract,
|
||||
},
|
||||
}
|
||||
|
||||
export type MarketPlaceInputs = InferContractRouterInputs<typeof marketplaceRouterContract>
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
"autoUpdate.upgradeModePlaceholder.partial": "Only selected plugins will auto-update. No plugins are currently selected, so no plugins will auto-update.",
|
||||
"category.agents": "Agent Strategies",
|
||||
"category.all": "All",
|
||||
"category.allTypes": "All types",
|
||||
"category.bundles": "Bundles",
|
||||
"category.datasources": "Data Sources",
|
||||
"category.extensions": "Extensions",
|
||||
@@ -194,15 +195,42 @@
|
||||
"marketplace.difyMarketplace": "Dify Marketplace",
|
||||
"marketplace.discover": "Discover",
|
||||
"marketplace.empower": "Empower your AI development",
|
||||
"marketplace.featured": "Featured",
|
||||
"marketplace.installs": "installs",
|
||||
"marketplace.moreFrom": "More from Marketplace",
|
||||
"marketplace.noPluginFound": "No plugin found",
|
||||
"marketplace.ourTopPicks": "Our top picks to get you started",
|
||||
"marketplace.partnerTip": "Verified by a Dify partner",
|
||||
"marketplace.pluginsHeroSubtitle": "Use community-built plugins to power your AI development.",
|
||||
"marketplace.pluginsHeroTitle": "Discover. Extend. Build.",
|
||||
"marketplace.pluginsResult": "{{num}} results",
|
||||
"marketplace.searchBreadcrumbMarketplace": "Marketplace",
|
||||
"marketplace.searchBreadcrumbSearch": "Search",
|
||||
"marketplace.searchDropdown.byAuthor": "by {{author}}",
|
||||
"marketplace.searchDropdown.enter": "Enter",
|
||||
"marketplace.searchDropdown.plugins": "Plugins",
|
||||
"marketplace.searchDropdown.showAllResults": "Show all search results",
|
||||
"marketplace.searchFilterAll": "All",
|
||||
"marketplace.searchFilterCreators": "Creators",
|
||||
"marketplace.searchFilterPlugins": "Plugins",
|
||||
"marketplace.searchFilterTags": "Tags",
|
||||
"marketplace.searchFilterTypes": "Types",
|
||||
"marketplace.searchResultsFor": "Results for",
|
||||
"marketplace.sortBy": "Sort by",
|
||||
"marketplace.sortOption.firstReleased": "First Released",
|
||||
"marketplace.sortOption.mostPopular": "Most Popular",
|
||||
"marketplace.sortOption.newlyReleased": "Newly Released",
|
||||
"marketplace.sortOption.recentlyUpdated": "Recently Updated",
|
||||
"marketplace.templatesHeroSubtitle": "Community-built workflow templates — ready to use, remix, and deploy.",
|
||||
"marketplace.templatesHeroTitle": "Create. Remix. Deploy.",
|
||||
"marketplace.templateCategory.all": "All",
|
||||
"marketplace.templateCategory.marketing": "Marketing",
|
||||
"marketplace.templateCategory.sales": "Sales",
|
||||
"marketplace.templateCategory.support": "Support",
|
||||
"marketplace.templateCategory.operations": "Operations",
|
||||
"marketplace.templateCategory.it": "IT",
|
||||
"marketplace.templateCategory.knowledge": "Knowledge",
|
||||
"marketplace.templateCategory.design": "Design",
|
||||
"marketplace.verifiedTip": "Verified by Dify",
|
||||
"marketplace.viewMore": "View more",
|
||||
"metadata.title": "Plugins",
|
||||
@@ -210,6 +238,7 @@
|
||||
"pluginInfoModal.release": "Release",
|
||||
"pluginInfoModal.repository": "Repository",
|
||||
"pluginInfoModal.title": "Plugin info",
|
||||
"plugins": "Plugins",
|
||||
"privilege.admins": "Admins",
|
||||
"privilege.everyone": "Everyone",
|
||||
"privilege.noone": "No one",
|
||||
@@ -222,6 +251,7 @@
|
||||
"readmeInfo.noReadmeAvailable": "No README available",
|
||||
"readmeInfo.title": "README",
|
||||
"requestAPlugin": "Request a plugin",
|
||||
"requestSubmitPlugin": "Request / Submit",
|
||||
"search": "Search",
|
||||
"searchCategories": "Search Categories",
|
||||
"searchInMarketplace": "Search in Marketplace",
|
||||
@@ -241,6 +271,7 @@
|
||||
"task.installingWithSuccess": "Installing {{installingLength}} plugins, {{successLength}} success.",
|
||||
"task.runningPlugins": "Installing Plugins",
|
||||
"task.successPlugins": "Successfully Installed Plugins",
|
||||
"templates": "Templates",
|
||||
"upgrade.close": "Close",
|
||||
"upgrade.description": "About to install the following plugin",
|
||||
"upgrade.successfulTitle": "Install successful",
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
"autoUpdate.upgradeModePlaceholder.partial": "仅选定的插件将自动更新。目前未选择任何插件,因此不会自动更新任何插件。",
|
||||
"category.agents": "Agent 策略",
|
||||
"category.all": "全部",
|
||||
"category.allTypes": "所有类型",
|
||||
"category.bundles": "插件集",
|
||||
"category.datasources": "数据源",
|
||||
"category.extensions": "扩展",
|
||||
@@ -194,15 +195,42 @@
|
||||
"marketplace.difyMarketplace": "Dify 市场",
|
||||
"marketplace.discover": "探索",
|
||||
"marketplace.empower": "助力您的 AI 开发",
|
||||
"marketplace.featured": "精选",
|
||||
"marketplace.installs": "次安装",
|
||||
"marketplace.moreFrom": "更多来自市场",
|
||||
"marketplace.noPluginFound": "未找到插件",
|
||||
"marketplace.ourTopPicks": "我们精选推荐",
|
||||
"marketplace.partnerTip": "此插件由 Dify 合作伙伴认证",
|
||||
"marketplace.pluginsHeroSubtitle": "使用社区构建的插件为您的 AI 开发提供动力。",
|
||||
"marketplace.pluginsHeroTitle": "探索。扩展。构建。",
|
||||
"marketplace.pluginsResult": "{{num}} 个插件结果",
|
||||
"marketplace.searchBreadcrumbMarketplace": "市场",
|
||||
"marketplace.searchBreadcrumbSearch": "搜索",
|
||||
"marketplace.searchDropdown.byAuthor": "由 {{author}} 提供",
|
||||
"marketplace.searchDropdown.enter": "输入",
|
||||
"marketplace.searchDropdown.plugins": "插件",
|
||||
"marketplace.searchDropdown.showAllResults": "显示所有搜索结果",
|
||||
"marketplace.searchFilterAll": "全部",
|
||||
"marketplace.searchFilterCreators": "创作者",
|
||||
"marketplace.searchFilterPlugins": "插件",
|
||||
"marketplace.searchFilterTags": "标签",
|
||||
"marketplace.searchFilterTypes": "类型",
|
||||
"marketplace.searchResultsFor": "搜索结果",
|
||||
"marketplace.sortBy": "排序方式",
|
||||
"marketplace.sortOption.firstReleased": "首次发布",
|
||||
"marketplace.sortOption.mostPopular": "最受欢迎",
|
||||
"marketplace.sortOption.newlyReleased": "最新发布",
|
||||
"marketplace.sortOption.recentlyUpdated": "最近更新",
|
||||
"marketplace.templatesHeroSubtitle": "社区构建的工作流模板 —— 随时可使用、复刻和部署。",
|
||||
"marketplace.templatesHeroTitle": "创建。复刻。部署。",
|
||||
"marketplace.templateCategory.all": "全部",
|
||||
"marketplace.templateCategory.marketing": "营销",
|
||||
"marketplace.templateCategory.sales": "销售",
|
||||
"marketplace.templateCategory.support": "支持",
|
||||
"marketplace.templateCategory.operations": "运营",
|
||||
"marketplace.templateCategory.it": "IT",
|
||||
"marketplace.templateCategory.knowledge": "知识",
|
||||
"marketplace.templateCategory.design": "设计",
|
||||
"marketplace.verifiedTip": "此插件由 Dify 认证",
|
||||
"marketplace.viewMore": "查看更多",
|
||||
"metadata.title": "插件",
|
||||
@@ -210,6 +238,7 @@
|
||||
"pluginInfoModal.release": "发布版本",
|
||||
"pluginInfoModal.repository": "仓库",
|
||||
"pluginInfoModal.title": "插件信息",
|
||||
"plugins": "插件",
|
||||
"privilege.admins": "管理员",
|
||||
"privilege.everyone": "所有人",
|
||||
"privilege.noone": "无人",
|
||||
@@ -222,6 +251,7 @@
|
||||
"readmeInfo.noReadmeAvailable": "README 文档不可用",
|
||||
"readmeInfo.title": "README",
|
||||
"requestAPlugin": "申请插件",
|
||||
"requestSubmitPlugin": "申请并发布插件",
|
||||
"search": "搜索",
|
||||
"searchCategories": "搜索类别",
|
||||
"searchInMarketplace": "在 Marketplace 中搜索",
|
||||
@@ -241,6 +271,7 @@
|
||||
"task.installingWithSuccess": "{{installingLength}} 个插件安装中,{{successLength}} 安装成功",
|
||||
"task.runningPlugins": "正在安装的插件",
|
||||
"task.successPlugins": "安装成功的插件",
|
||||
"templates": "模板",
|
||||
"upgrade.close": "关闭",
|
||||
"upgrade.description": "即将安装以下插件",
|
||||
"upgrade.successfulTitle": "安装成功",
|
||||
|
||||
@@ -121,6 +121,7 @@
|
||||
"mermaid": "11.11.0",
|
||||
"mime": "4.1.0",
|
||||
"mitt": "3.0.1",
|
||||
"motion": "12.31.0",
|
||||
"negotiator": "1.0.0",
|
||||
"next": "16.1.5",
|
||||
"next-themes": "0.4.6",
|
||||
|
||||
BIN
web/public/marketplace/hero-bg.jpg
Normal file
BIN
web/public/marketplace/hero-bg.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 201 KiB |
27
web/public/marketplace/hero-gradient-noise.svg
Normal file
27
web/public/marketplace/hero-gradient-noise.svg
Normal file
@@ -0,0 +1,27 @@
|
||||
<svg width="1416" height="200" viewBox="0 0 1416 200" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_n_21362_44659)">
|
||||
<rect width="1416" height="200" fill="url(#paint0_linear_21362_44659)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_n_21362_44659" x="0" y="0" width="1416" height="200" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.83333331346511841 0.83333331346511841" stitchTiles="stitch" numOctaves="3" result="noise" seed="3192" />
|
||||
<feColorMatrix in="noise" type="luminanceToAlpha" result="alphaNoise" />
|
||||
<feComponentTransfer in="alphaNoise" result="coloredNoise1">
|
||||
<feFuncA type="discrete" tableValues="1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 "/>
|
||||
</feComponentTransfer>
|
||||
<feComposite operator="in" in2="shape" in="coloredNoise1" result="noise1Clipped" />
|
||||
<feFlood flood-color="rgba(0, 0, 0, 0.18)" result="color1Flood" />
|
||||
<feComposite operator="in" in2="noise1Clipped" in="color1Flood" result="color1" />
|
||||
<feMerge result="effect1_noise_21362_44659">
|
||||
<feMergeNode in="shape" />
|
||||
<feMergeNode in="color1" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_21362_44659" x1="708" y1="0" x2="708" y2="200" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-opacity="0.3"/>
|
||||
<stop offset="0.661631" stop-color="#0033FF" stop-opacity="0.3"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
88
web/utils/template.ts
Normal file
88
web/utils/template.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { Viewport } from 'reactflow'
|
||||
import type { Edge, Node } from '@/app/components/workflow/types'
|
||||
import { load as yamlLoad } from 'js-yaml'
|
||||
|
||||
type GraphPayload = {
|
||||
nodes?: Node[]
|
||||
edges?: Edge[]
|
||||
viewport?: Viewport
|
||||
}
|
||||
|
||||
type DslPayload = {
|
||||
workflow?: {
|
||||
graph?: GraphPayload
|
||||
}
|
||||
graph?: GraphPayload
|
||||
} | null
|
||||
|
||||
export type ParsedGraph = {
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
viewport: Viewport
|
||||
} | null
|
||||
|
||||
export const parseGraphFromDsl = (dslContent: string): ParsedGraph => {
|
||||
if (!dslContent)
|
||||
return null
|
||||
|
||||
try {
|
||||
const data = yamlLoad(dslContent) as DslPayload
|
||||
const graph = data?.workflow?.graph ?? data?.graph
|
||||
if (!graph || !graph.nodes || !graph.edges)
|
||||
return null
|
||||
|
||||
return {
|
||||
nodes: graph.nodes || [],
|
||||
edges: graph.edges || [],
|
||||
viewport: graph.viewport || { x: 0, y: 0, zoom: 0.5 },
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
type UsedCountFormatOptions = {
|
||||
precision?: number
|
||||
rounding?: 'round' | 'floor'
|
||||
}
|
||||
|
||||
export const formatUsedCount = (count?: number, options: UsedCountFormatOptions = {}) => {
|
||||
if (!count)
|
||||
return null
|
||||
if (count < 1000)
|
||||
return String(count)
|
||||
|
||||
const precision = options.precision ?? 1
|
||||
const rounding = options.rounding ?? 'round'
|
||||
const base = count / 1000
|
||||
const factor = 10 ** precision
|
||||
const rounded = rounding === 'floor'
|
||||
? Math.floor(base * factor) / factor
|
||||
: Math.round(base * factor) / factor
|
||||
|
||||
const display = precision <= 0
|
||||
? String(rounded)
|
||||
: (rounded % 1 === 0 ? String(rounded) : rounded.toFixed(precision))
|
||||
|
||||
return `${display}k`
|
||||
}
|
||||
|
||||
type TranslationFn = (key: string, options?: Record<string, unknown>) => string
|
||||
|
||||
export const formatRelativeTime = (dateStr: string, t: TranslationFn) => {
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (diffDays < 1)
|
||||
return t('detail.today')
|
||||
if (diffDays < 7)
|
||||
return t('detail.daysAgo', { count: diffDays })
|
||||
if (diffDays < 30)
|
||||
return t('detail.weeksAgo', { count: Math.floor(diffDays / 7) })
|
||||
if (diffDays < 365)
|
||||
return t('detail.monthsAgo', { count: Math.floor(diffDays / 30) })
|
||||
return t('detail.yearsAgo', { count: Math.floor(diffDays / 365) })
|
||||
}
|
||||
Reference in New Issue
Block a user