-
{t('marketplace.pluginsResult', { ns: 'plugin', num: pluginsTotal })}
-
-
+ {plugins && !isSearchMode && (
+
+
{t('marketplace.pluginsResult', { ns: 'plugin', num: pluginsTotal })}
+
+
+
+ )}
+ {isSearchMode && (
+
+
+ {
+ setSearchScope(value as SearchScope)
+ }}
+ options={searchScopeOptions}
+ />
+ {
+ if (categories.length === 0) {
+ handleActivePluginTypeChange(PLUGIN_TYPE_SEARCH_MAP.all)
+ return
+ }
+ handleActivePluginTypeChange(categories[categories.length - 1] as ActivePluginType)
+ }}
+ />
+
- )
- }
+
+
+ )}
{
isLoading && page === 1 && (
diff --git a/web/app/components/plugins/marketplace/marketplace-header.tsx b/web/app/components/plugins/marketplace/marketplace-header.tsx
new file mode 100644
index 0000000000..f5115c1a31
--- /dev/null
+++ b/web/app/components/plugins/marketplace/marketplace-header.tsx
@@ -0,0 +1,20 @@
+'use client'
+
+import { useMarketplaceSearchMode } from './atoms'
+import { Description } from './description'
+import SearchResultsHeader from './search-results-header'
+
+type MarketplaceHeaderProps = {
+ descriptionClassName?: string
+}
+
+const MarketplaceHeader = ({ descriptionClassName }: MarketplaceHeaderProps) => {
+ const isSearchMode = useMarketplaceSearchMode()
+
+ if (isSearchMode)
+ return
+
+ return
+}
+
+export default MarketplaceHeader
diff --git a/web/app/components/plugins/marketplace/plugin-type-icons.tsx b/web/app/components/plugins/marketplace/plugin-type-icons.tsx
new file mode 100644
index 0000000000..684a1c43ec
--- /dev/null
+++ b/web/app/components/plugins/marketplace/plugin-type-icons.tsx
@@ -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.tool]: RiHammerLine,
+ [PluginCategoryEnum.model]: RiBrain2Line,
+ [PluginCategoryEnum.datasource]: RiDatabase2Line,
+ [PluginCategoryEnum.trigger]: TriggerIcon,
+ [PluginCategoryEnum.agent]: RiSpeakAiLine,
+ [PluginCategoryEnum.extension]: RiPuzzle2Line,
+}
diff --git a/web/app/components/plugins/marketplace/plugin-type-switch.tsx b/web/app/components/plugins/marketplace/plugin-type-switch.tsx
index 916d290638..d96425bf5c 100644
--- a/web/app/components/plugins/marketplace/plugin-type-switch.tsx
+++ b/web/app/components/plugins/marketplace/plugin-type-switch.tsx
@@ -1,20 +1,16 @@
'use client'
import type { ActivePluginType } from './constants'
+import type { PluginCategoryEnum } from '@/app/components/plugins/types'
import { useTranslation } from '#i18n'
import {
RiApps2Line,
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'
+import { MARKETPLACE_TYPE_ICON_COMPONENTS } from './plugin-type-icons'
type PluginTypeSwitchProps = {
className?: string
@@ -30,6 +26,15 @@ const PluginTypeSwitch = ({
const isHeroVariant = variant === 'hero'
+ const getTypeIcon = (value: ActivePluginType) => {
+ if (value === PLUGIN_TYPE_SEARCH_MAP.all)
+ return isHeroVariant ? : null
+ if (value === PLUGIN_TYPE_SEARCH_MAP.bundle)
+ return
+ const Icon = MARKETPLACE_TYPE_ICON_COMPONENTS[value as PluginCategoryEnum]
+ return Icon ? : null
+ }
+
const options: Array<{
value: ActivePluginType
text: string
@@ -38,42 +43,42 @@ const PluginTypeSwitch = ({
{
value: PLUGIN_TYPE_SEARCH_MAP.all,
text: isHeroVariant ? t('category.allTypes', { ns: 'plugin' }) : t('category.all', { ns: 'plugin' }),
- icon: isHeroVariant ? : null,
+ icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.all),
},
{
value: PLUGIN_TYPE_SEARCH_MAP.model,
text: t('category.models', { ns: 'plugin' }),
- icon: ,
+ icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.model),
},
{
value: PLUGIN_TYPE_SEARCH_MAP.tool,
text: t('category.tools', { ns: 'plugin' }),
- icon: ,
+ icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.tool),
},
{
value: PLUGIN_TYPE_SEARCH_MAP.datasource,
text: t('category.datasources', { ns: 'plugin' }),
- icon: ,
+ icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.datasource),
},
{
value: PLUGIN_TYPE_SEARCH_MAP.trigger,
text: t('category.triggers', { ns: 'plugin' }),
- icon: ,
+ icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.trigger),
},
{
value: PLUGIN_TYPE_SEARCH_MAP.agent,
text: t('category.agents', { ns: 'plugin' }),
- icon: ,
+ icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.agent),
},
{
value: PLUGIN_TYPE_SEARCH_MAP.extension,
text: t('category.extensions', { ns: 'plugin' }),
- icon: ,
+ icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.extension),
},
{
value: PLUGIN_TYPE_SEARCH_MAP.bundle,
text: t('category.bundles', { ns: 'plugin' }),
- icon: ,
+ icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.bundle),
},
]
diff --git a/web/app/components/plugins/marketplace/search-box/index.spec.tsx b/web/app/components/plugins/marketplace/search-box/index.spec.tsx
index 85be82cb33..8f150957d0 100644
--- a/web/app/components/plugins/marketplace/search-box/index.spec.tsx
+++ b/web/app/components/plugins/marketplace/search-box/index.spec.tsx
@@ -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,32 +16,72 @@ 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 = {
'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
},
}),
}))
+vi.mock('ahooks', () => ({
+ useDebounce: (value: string) => value,
+}))
+
+vi.mock('jotai', async () => {
+ const actual = await vi.importActual('jotai')
+ return {
+ ...actual,
+ useSetAtom: () => vi.fn(),
+ }
+})
+
+vi.mock('@/hooks/use-i18n', () => ({
+ useRenderI18nObject: () => (value: Record | 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 {
+ mockSearchPluginText,
+ mockHandleSearchPluginTextChange,
+ mockFilterPluginTags,
+ mockHandleFilterPluginTagsChange,
+ mockActivePluginType,
+ mockSortValue,
+} = vi.hoisted(() => {
return {
mockSearchPluginText: '',
mockHandleSearchPluginTextChange: vi.fn(),
mockFilterPluginTags: [] as string[],
mockHandleFilterPluginTagsChange: vi.fn(),
+ mockActivePluginType: 'all',
+ mockSortValue: {
+ sortBy: 'install_count',
+ sortOrder: 'DESC',
+ },
}
})
vi.mock('../atoms', () => ({
useSearchPluginText: () => [mockSearchPluginText, mockHandleSearchPluginTextChange],
useFilterPluginTags: () => [mockFilterPluginTags, mockHandleFilterPluginTagsChange],
+ useActivePluginType: () => [mockActivePluginType, vi.fn()],
+ useMarketplaceSortValue: () => mockSortValue,
+ searchModeAtom: {},
}))
// Mock useTags hook
@@ -60,8 +103,57 @@ 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', () => ({
+ useMarketplacePlugins: () => ({
+ data: { pages: [{ plugins: mockDropdownPlugins }] },
+ isLoading: false,
+ }),
+}))
+
+const createPlugin = (overrides: Partial = {}): 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 +207,7 @@ describe('SearchBox', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPortalOpenState = false
+ mockDropdownPlugins = []
})
// ================================
@@ -424,6 +517,64 @@ describe('SearchBox', () => {
expect(onSearchChange).toHaveBeenCalledWith(' ')
})
})
+
+ // ================================
+ // Submission Tests
+ // ================================
+ describe('Submission', () => {
+ it('should call onSearchSubmit when pressing Enter', () => {
+ const onSearchSubmit = vi.fn()
+ render()
+
+ 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(
+ ,
+ )
+
+ 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(
+ ,
+ )
+
+ fireEvent.click(screen.getByText('Show all search results'))
+
+ expect(onShowAll).toHaveBeenCalledTimes(1)
+ })
+ })
})
// ================================
@@ -433,6 +584,7 @@ describe('SearchBoxWrapper', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPortalOpenState = false
+ mockDropdownPlugins = []
})
describe('Rendering', () => {
@@ -457,12 +609,22 @@ describe('SearchBoxWrapper', () => {
})
describe('Hook Integration', () => {
- it('should call handleSearchPluginTextChange when search changes', () => {
+ it('should not commit search when input changes', () => {
render()
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'new search' } })
+ expect(mockHandleSearchPluginTextChange).not.toHaveBeenCalled()
+ })
+
+ it('should commit search when pressing Enter', () => {
+ render()
+
+ const input = screen.getByRole('textbox')
+ fireEvent.change(input, { target: { value: 'new search' } })
+ fireEvent.keyDown(input, { key: 'Enter' })
+
expect(mockHandleSearchPluginTextChange).toHaveBeenCalledWith('new search')
})
})
diff --git a/web/app/components/plugins/marketplace/search-box/index.tsx b/web/app/components/plugins/marketplace/search-box/index.tsx
index b6e1f8ee70..e595c43c5b 100644
--- a/web/app/components/plugins/marketplace/search-box/index.tsx
+++ b/web/app/components/plugins/marketplace/search-box/index.tsx
@@ -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}
/>
{
diff --git a/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx b/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx
index 39f2f1bdc6..3cd5cff0be 100644
--- a/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx
+++ b/web/app/components/plugins/marketplace/search-box/search-box-wrapper.tsx
@@ -1,9 +1,28 @@
'use client'
+import type { PluginsSearchParams } from '../types'
import { useTranslation } from '#i18n'
+import { useDebounce } from 'ahooks'
+import { useSetAtom } from 'jotai'
+import { useMemo, useState } from 'react'
+import {
+ PortalToFollowElem,
+ PortalToFollowElemContent,
+ PortalToFollowElemTrigger,
+} from '@/app/components/base/portal-to-follow-elem'
import { cn } from '@/utils/classnames'
-import { useFilterPluginTags, useSearchPluginText } from '../atoms'
+import {
+ searchModeAtom,
+ useActivePluginType,
+ useFilterPluginTags,
+ useMarketplaceSortValue,
+ useSearchPluginText,
+} from '../atoms'
+import { PLUGIN_TYPE_SEARCH_MAP } from '../constants'
+import { useMarketplacePlugins } from '../query'
+import { getMarketplaceListFilterType } from '../utils'
import SearchBox from './index'
+import SearchDropdown from './search-dropdown'
type SearchBoxWrapperProps = {
wrapperClassName?: string
@@ -16,18 +35,92 @@ const SearchBoxWrapper = ({
const { t } = useTranslation()
const [searchPluginText, handleSearchPluginTextChange] = useSearchPluginText()
const [filterPluginTags, handleFilterPluginTagsChange] = useFilterPluginTags()
+ const [activePluginType] = useActivePluginType()
+ const sort = useMarketplaceSortValue()
+ const setSearchMode = useSetAtom(searchModeAtom)
+ const committedSearch = searchPluginText || ''
+ 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(() => {
+ if (!hasDraft)
+ return undefined
+ const filterType = getMarketplaceListFilterType(activePluginType) as PluginsSearchParams['type']
+ return {
+ query: debouncedDraft.trim(),
+ category: activePluginType === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginType,
+ tags: filterPluginTags,
+ sort_by: sort.sortBy,
+ sort_order: sort.sortOrder,
+ type: filterType,
+ page_size: 3,
+ }
+ }, [activePluginType, debouncedDraft, filterPluginTags, hasDraft, sort.sortBy, sort.sortOrder])
+
+ const dropdownQuery = useMarketplacePlugins(dropdownQueryParams)
+ const dropdownPlugins = dropdownQuery.data?.pages[0]?.plugins || []
+
+ const handleSubmit = () => {
+ const trimmed = draftSearch.trim()
+ if (!trimmed)
+ return
+ handleSearchPluginTextChange(trimmed)
+ setSearchMode(true)
+ setIsFocused(false)
+ }
+
+ const inputValue = isFocused ? draftSearch : committedSearch
+ const isDropdownOpen = hasDraft && (isFocused || isHoveringDropdown)
return (
-
+
+
+
+ {
+ setDraftSearch(committedSearch)
+ setIsFocused(true)
+ }}
+ onSearchBlur={() => {
+ if (!isHoveringDropdown)
+ setIsFocused(false)
+ }}
+ tags={filterPluginTags}
+ onTagsChange={handleFilterPluginTagsChange}
+ placeholder={t('searchPlugins', { ns: 'plugin' })}
+ usedInMarketplace
+ />
+
+
+ setIsHoveringDropdown(true)}
+ onMouseLeave={() => setIsHoveringDropdown(false)}
+ onMouseDown={(event) => {
+ event.preventDefault()
+ }}
+ >
+
+
+
)
}
diff --git a/web/app/components/plugins/marketplace/search-box/search-dropdown/index.tsx b/web/app/components/plugins/marketplace/search-box/search-dropdown/index.tsx
new file mode 100644
index 0000000000..7c8612b008
--- /dev/null
+++ b/web/app/components/plugins/marketplace/search-box/search-dropdown/index.tsx
@@ -0,0 +1,106 @@
+import type { Plugin } from '@/app/components/plugins/types'
+import { useTranslation } from '#i18n'
+import { RiArrowRightLine } from '@remixicon/react'
+import Loading from '@/app/components/base/loading'
+import { useCategories } from '@/app/components/plugins/hooks'
+import { useRenderI18nObject } from '@/hooks/use-i18n'
+import { cn } from '@/utils/classnames'
+import { MARKETPLACE_TYPE_ICON_COMPONENTS } from '../../plugin-type-icons'
+import { getPluginDetailLinkInMarketplace } from '../../utils'
+
+type SearchDropdownProps = {
+ query: string
+ plugins: Plugin[]
+ onShowAll: () => void
+ isLoading?: boolean
+}
+
+const SearchDropdown = ({
+ query,
+ plugins,
+ onShowAll,
+ isLoading = false,
+}: SearchDropdownProps) => {
+ const { t } = useTranslation()
+ const getValueFromI18nObject = useRenderI18nObject()
+ const { categoriesMap } = useCategories(true)
+
+ return (
+
+
+ {isLoading && !plugins.length && (
+
+
+
+ )}
+ {!!plugins.length && (
+
+
+ {t('marketplace.searchDropdown.plugins', { ns: 'plugin' })}
+
+
+
+ )}
+
+
+
+
+
+ )
+}
+
+export default SearchDropdown
diff --git a/web/app/components/plugins/marketplace/search-box/trigger/index.ts b/web/app/components/plugins/marketplace/search-box/trigger/index.ts
new file mode 100644
index 0000000000..5a53d5dd14
--- /dev/null
+++ b/web/app/components/plugins/marketplace/search-box/trigger/index.ts
@@ -0,0 +1,2 @@
+export { default as MarketplaceTrigger } from './marketplace'
+export { default as ToolSelectorTrigger } from './tool-selector'
diff --git a/web/app/components/plugins/marketplace/search-results-header.tsx b/web/app/components/plugins/marketplace/search-results-header.tsx
new file mode 100644
index 0000000000..74c223bd76
--- /dev/null
+++ b/web/app/components/plugins/marketplace/search-results-header.tsx
@@ -0,0 +1,30 @@
+'use client'
+
+import { useTranslation } from '#i18n'
+import { useSearchPluginText } from './atoms'
+
+const SearchResultsHeader = () => {
+ const { t } = useTranslation('plugin')
+ const [searchPluginText] = useSearchPluginText()
+
+ return (
+
+
+ {t('marketplace.searchBreadcrumbMarketplace')}
+ /
+ {t('marketplace.searchBreadcrumbSearch')}
+
+
+
+ {t('marketplace.searchResultsFor')}
+
+
+ {searchPluginText || ''}
+
+
+
+
+ )
+}
+
+export default SearchResultsHeader
diff --git a/web/app/components/plugins/marketplace/sticky-search-and-switch-wrapper.tsx b/web/app/components/plugins/marketplace/sticky-search-and-switch-wrapper.tsx
deleted file mode 100644
index ac1c027a2d..0000000000
--- a/web/app/components/plugins/marketplace/sticky-search-and-switch-wrapper.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-'use client'
-
-import { cn } from '@/utils/classnames'
-import SearchBoxWrapper from './search-box/search-box-wrapper'
-
-type StickySearchAndSwitchWrapperProps = {
- pluginTypeSwitchClassName?: string
-}
-
-const StickySearchAndSwitchWrapper = ({
- pluginTypeSwitchClassName,
-}: StickySearchAndSwitchWrapperProps) => {
- const hasCustomTopClass = pluginTypeSwitchClassName?.includes('top-')
-
- return (
-
-
-
- )
-}
-
-export default StickySearchAndSwitchWrapper
diff --git a/web/app/components/plugins/plugin-page/index.tsx b/web/app/components/plugins/plugin-page/index.tsx
index 30ece18ac3..10050617b4 100644
--- a/web/app/components/plugins/plugin-page/index.tsx
+++ b/web/app/components/plugins/plugin-page/index.tsx
@@ -151,7 +151,7 @@ const PluginPage = ({
onChange={setActiveTab}
options={options}
/>
-
+ {!isPluginsTab && }
{
diff --git a/web/i18n/en-US/plugin.json b/web/i18n/en-US/plugin.json
index c7e4eb4a05..b66893022a 100644
--- a/web/i18n/en-US/plugin.json
+++ b/web/i18n/en-US/plugin.json
@@ -200,10 +200,22 @@
"marketplace.heroTitle": "Discover. Extend. Build.",
"marketplace.installs": "installs",
"marketplace.moreFrom": "More from Marketplace",
- "marketplace.ourTopPicks": "Our top picks to get you started",
"marketplace.noPluginFound": "No plugin found",
+ "marketplace.ourTopPicks": "Our top picks to get you started",
"marketplace.partnerTip": "Verified by a Dify partner",
"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",
diff --git a/web/i18n/zh-Hans/plugin.json b/web/i18n/zh-Hans/plugin.json
index e2b625dc8a..c97744fd33 100644
--- a/web/i18n/zh-Hans/plugin.json
+++ b/web/i18n/zh-Hans/plugin.json
@@ -200,10 +200,22 @@
"marketplace.heroTitle": "探索。扩展。构建。",
"marketplace.installs": "次安装",
"marketplace.moreFrom": "更多来自市场",
- "marketplace.ourTopPicks": "我们精选推荐",
"marketplace.noPluginFound": "未找到插件",
+ "marketplace.ourTopPicks": "我们精选推荐",
"marketplace.partnerTip": "此插件由 Dify 合作伙伴认证",
"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": "最受欢迎",
diff --git a/web/package.json b/web/package.json
index b8f8e3499f..478a493231 100644
--- a/web/package.json
+++ b/web/package.json
@@ -118,6 +118,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",
diff --git a/web/public/marketplace/hero-bg.jpg b/web/public/marketplace/hero-bg.jpg
new file mode 100644
index 0000000000..22de60a2be
Binary files /dev/null and b/web/public/marketplace/hero-bg.jpg differ
diff --git a/web/public/marketplace/hero-gradient-noise.svg b/web/public/marketplace/hero-gradient-noise.svg
new file mode 100644
index 0000000000..85eddea08e
--- /dev/null
+++ b/web/public/marketplace/hero-gradient-noise.svg
@@ -0,0 +1,27 @@
+