diff --git a/web/app/components/plugins/marketplace/atoms.ts b/web/app/components/plugins/marketplace/atoms.ts
index 15fa9cf6f5..17bf2fe857 100644
--- a/web/app/components/plugins/marketplace/atoms.ts
+++ b/web/app/components/plugins/marketplace/atoms.ts
@@ -52,6 +52,24 @@ export function useCreationType() {
return useQueryState('creationType', marketplaceSearchParamsParsers.creationType)
}
+// Search-page-specific filter hooks (separate from list-page category/tags)
+export function useSearchFilterCategories() {
+ return useQueryState('searchCategories', marketplaceSearchParamsParsers.searchCategories)
+}
+
+export function useSearchFilterLanguages() {
+ return useQueryState('searchLanguages', marketplaceSearchParamsParsers.searchLanguages)
+}
+
+export function useSearchFilterType() {
+ const [type, setType] = useQueryState('searchType', marketplaceSearchParamsParsers.searchType)
+ return [getValidatedPluginCategory(type), setType] as const
+}
+
+export function useSearchFilterTags() {
+ return useQueryState('searchTags', marketplaceSearchParamsParsers.searchTags)
+}
+
/**
* Not all categories have collections, so we need to
* force the search mode for those categories.
diff --git a/web/app/components/plugins/marketplace/search-page/constants.ts b/web/app/components/plugins/marketplace/search-page/constants.ts
new file mode 100644
index 0000000000..10da9c572b
--- /dev/null
+++ b/web/app/components/plugins/marketplace/search-page/constants.ts
@@ -0,0 +1,15 @@
+export type LanguageOption = {
+ value: string
+ label: string
+ nativeLabel: string
+}
+
+export const LANGUAGE_OPTIONS: LanguageOption[] = [
+ { value: 'en', label: 'English', nativeLabel: 'English' },
+ { value: 'zh-Hans', label: 'Simplified Chinese', nativeLabel: '简体中文' },
+ { value: 'zh-Hant', label: 'Traditional Chinese', nativeLabel: '繁體中文' },
+ { value: 'ja', label: 'Japanese', nativeLabel: '日本語' },
+ { value: 'es', label: 'Spanish', nativeLabel: 'Español' },
+ { value: 'fr', label: 'French', nativeLabel: 'Français' },
+ { value: 'ko', label: 'Korean', nativeLabel: '한국어' },
+]
diff --git a/web/app/components/plugins/marketplace/search-page/filter-chip.tsx b/web/app/components/plugins/marketplace/search-page/filter-chip.tsx
new file mode 100644
index 0000000000..b04ba82503
--- /dev/null
+++ b/web/app/components/plugins/marketplace/search-page/filter-chip.tsx
@@ -0,0 +1,167 @@
+'use client'
+
+import {
+ RiArrowDownSLine,
+ RiCheckLine,
+ RiCloseCircleFill,
+} from '@remixicon/react'
+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 { cn } from '@/utils/classnames'
+
+export type FilterOption = {
+ value: string
+ label: string
+}
+
+type FilterChipProps = {
+ label: string
+ options: FilterOption[]
+ value: string[]
+ onChange: (value: string[]) => void
+ multiple?: boolean
+ searchable?: boolean
+ searchPlaceholder?: string
+}
+
+const FilterChip = ({
+ label,
+ options,
+ value,
+ onChange,
+ multiple = true,
+ searchable = false,
+ searchPlaceholder = '',
+}: FilterChipProps) => {
+ const [open, setOpen] = useState(false)
+ const [searchText, setSearchText] = useState('')
+
+ const hasSelected = value.length > 0
+ const filteredOptions = searchable
+ ? options.filter(option => option.label.toLowerCase().includes(searchText.toLowerCase()))
+ : options
+
+ const getSelectedLabels = () => {
+ return value
+ .map(v => options.find(o => o.value === v)?.label)
+ .filter(Boolean)
+ .slice(0, 2)
+ .join(', ')
+ }
+
+ const handleSelect = (optionValue: string) => {
+ if (multiple) {
+ if (value.includes(optionValue))
+ onChange(value.filter(v => v !== optionValue))
+ else
+ onChange([...value, optionValue])
+ }
+ else {
+ onChange([optionValue])
+ setOpen(false)
+ }
+ }
+
+ const handleClear = (e: React.MouseEvent) => {
+ e.stopPropagation()
+ onChange([])
+ }
+
+ return (
+
+ setOpen(v => !v)}
+ >
+
+
+ {!hasSelected && (
+ {label}
+ )}
+ {hasSelected && (
+ <>
+ {label}
+
+ {getSelectedLabels()}
+
+ {value.length > 2 && (
+
+ +
+ {value.length - 2}
+
+ )}
+ >
+ )}
+
+ {hasSelected && (
+
+ )}
+ {!hasSelected && (
+
+ )}
+
+
+
+
+ {searchable && (
+
+ setSearchText(e.target.value)}
+ placeholder={searchPlaceholder}
+ />
+
+ )}
+
+ {filteredOptions.map(option => (
+
handleSelect(option.value)}
+ >
+ {multiple && (
+
+ )}
+
+ {option.label}
+
+ {!multiple && value.includes(option.value) && (
+
+ )}
+
+ ))}
+
+
+
+
+ )
+}
+
+export default FilterChip
diff --git a/web/app/components/plugins/marketplace/search-page/index.tsx b/web/app/components/plugins/marketplace/search-page/index.tsx
index 8e7bae7c54..d1540546ec 100644
--- a/web/app/components/plugins/marketplace/search-page/index.tsx
+++ b/web/app/components/plugins/marketplace/search-page/index.tsx
@@ -8,8 +8,17 @@ 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 { useMarketplacePluginSortValue, useMarketplaceTemplateSortValue, useSearchTab, useSearchText } from '../atoms'
-import { PLUGIN_TYPE_SEARCH_MAP } from '../constants'
+import {
+ useMarketplacePluginSortValue,
+ useMarketplaceTemplateSortValue,
+ useSearchFilterCategories,
+ useSearchFilterLanguages,
+ useSearchFilterTags,
+ useSearchFilterType,
+ useSearchTab,
+ useSearchText,
+} from '../atoms'
+import { CATEGORY_ALL, PLUGIN_TYPE_SEARCH_MAP } from '../constants'
import Empty from '../empty'
import { useMarketplaceContainerScroll } from '../hooks'
import CardWrapper from '../list/card-wrapper'
@@ -18,6 +27,8 @@ import { useMarketplaceCreators, useMarketplacePlugins, useMarketplaceTemplates
import SortDropdown from '../sort-dropdown'
import { getPluginFilterType, mapTemplateDetailToTemplate } from '../utils'
import CreatorCard from './creator-card'
+import PluginFilters from './plugin-filters'
+import TemplateFilters from './template-filters'
const PAGE_SIZE = 40
const ALL_TAB_PREVIEW_SIZE = 8
@@ -39,31 +50,53 @@ const SearchPage = () => {
const pluginSort = useMarketplacePluginSortValue()
const templateSort = useMarketplaceTemplateSortValue()
+ // Search-page-specific filters
+ const [searchFilterCategories] = useSearchFilterCategories()
+ const [searchFilterLanguages] = useSearchFilterLanguages()
+ const [searchFilterType] = useSearchFilterType()
+ const [searchFilterTags] = useSearchFilterTags()
+
const query = debouncedQuery === ZERO_WIDTH_SPACE ? '' : debouncedQuery.trim()
const hasQuery = !!searchText && (!!query || searchText === ZERO_WIDTH_SPACE)
const pluginsParams = useMemo(() => {
if (!hasQuery)
return undefined
+ const category = searchTab === 'plugins' && searchFilterType !== CATEGORY_ALL
+ ? searchFilterType
+ : undefined
+ const tags = searchTab === 'plugins' && searchFilterTags.length > 0
+ ? searchFilterTags
+ : undefined
return {
query,
page_size: searchTab === 'all' ? ALL_TAB_PREVIEW_SIZE : PAGE_SIZE,
sort_by: pluginSort.sortBy,
sort_order: pluginSort.sortOrder,
- type: getPluginFilterType(PLUGIN_TYPE_SEARCH_MAP.all),
+ category,
+ tags,
+ type: getPluginFilterType(category || PLUGIN_TYPE_SEARCH_MAP.all),
} as PluginsSearchParams
- }, [hasQuery, query, searchTab, pluginSort])
+ }, [hasQuery, query, searchTab, pluginSort, searchFilterType, searchFilterTags])
const templatesParams = useMemo(() => {
if (!hasQuery)
return undefined
+ const categories = searchTab === 'templates' && searchFilterCategories.length > 0
+ ? searchFilterCategories
+ : undefined
+ const languages = searchTab === 'templates' && searchFilterLanguages.length > 0
+ ? searchFilterLanguages
+ : undefined
return {
query,
page_size: searchTab === 'all' ? ALL_TAB_PREVIEW_SIZE : PAGE_SIZE,
sort_by: templateSort.sortBy,
sort_order: templateSort.sortOrder,
+ categories,
+ languages,
}
- }, [hasQuery, query, searchTab, templateSort])
+ }, [hasQuery, query, searchTab, templateSort, searchFilterCategories, searchFilterLanguages])
const creatorsParams = useMemo(() => {
if (!hasQuery)
@@ -213,13 +246,17 @@ const SearchPage = () => {
className="relative flex grow flex-col bg-background-default-subtle px-12 py-2"
>
-
setSearchTab(v as SearchTab)}
- options={tabOptions}
- />
+
+
setSearchTab(v as SearchTab)}
+ options={tabOptions}
+ />
+ {searchTab === 'templates' && }
+ {searchTab === 'plugins' && }
+
{(searchTab === 'templates' || searchTab === 'plugins') && }
diff --git a/web/app/components/plugins/marketplace/search-page/plugin-filters.tsx b/web/app/components/plugins/marketplace/search-page/plugin-filters.tsx
new file mode 100644
index 0000000000..22dfa8f437
--- /dev/null
+++ b/web/app/components/plugins/marketplace/search-page/plugin-filters.tsx
@@ -0,0 +1,60 @@
+'use client'
+
+import type { FilterOption } from './filter-chip'
+import { useTranslation } from '#i18n'
+import { useMemo } from 'react'
+import { useTags } from '@/app/components/plugins/hooks'
+import { useSearchFilterTags, useSearchFilterType } from '../atoms'
+import { usePluginCategoryText } from '../category-switch/category-text'
+import { CATEGORY_ALL, PLUGIN_TYPE_SEARCH_MAP } from '../constants'
+import FilterChip from './filter-chip'
+
+const PluginFilters = () => {
+ const { t } = useTranslation()
+ const [searchType, setSearchType] = useSearchFilterType()
+ const [searchTags, setSearchTags] = useSearchFilterTags()
+ const getPluginCategoryText = usePluginCategoryText()
+ const { tags: tagsList } = useTags()
+
+ const typeOptions: FilterOption[] = useMemo(() => {
+ return Object.values(PLUGIN_TYPE_SEARCH_MAP).map(value => ({
+ value,
+ label: getPluginCategoryText(value),
+ }))
+ }, [getPluginCategoryText])
+
+ const tagOptions: FilterOption[] = useMemo(() => {
+ return tagsList.map(tag => ({
+ value: tag.name,
+ label: tag.label,
+ }))
+ }, [tagsList])
+
+ const typeValue = searchType === CATEGORY_ALL ? [] : [searchType]
+
+ return (
+
+ {
+ const newType = v.length > 0 ? v[v.length - 1] : CATEGORY_ALL
+ setSearchType(newType === CATEGORY_ALL ? null : newType)
+ }}
+ multiple={false}
+ />
+ setSearchTags(v.length ? v : null)}
+ multiple
+ searchable
+ searchPlaceholder={t('searchTags', { ns: 'pluginTags' }) || ''}
+ />
+
+ )
+}
+
+export default PluginFilters
diff --git a/web/app/components/plugins/marketplace/search-page/template-filters.tsx b/web/app/components/plugins/marketplace/search-page/template-filters.tsx
new file mode 100644
index 0000000000..24a50633c4
--- /dev/null
+++ b/web/app/components/plugins/marketplace/search-page/template-filters.tsx
@@ -0,0 +1,55 @@
+'use client'
+
+import type { FilterOption } from './filter-chip'
+import { useTranslation } from '#i18n'
+import { useMemo } from 'react'
+import { useSearchFilterCategories, useSearchFilterLanguages } from '../atoms'
+import { useTemplateCategoryText } from '../category-switch/category-text'
+import { TEMPLATE_CATEGORY_MAP } from '../constants'
+import { LANGUAGE_OPTIONS } from './constants'
+import FilterChip from './filter-chip'
+
+const TemplateFilters = () => {
+ const { t } = useTranslation()
+ const [categories, setCategories] = useSearchFilterCategories()
+ const [languages, setLanguages] = useSearchFilterLanguages()
+ const getTemplateCategoryText = useTemplateCategoryText()
+
+ const categoryOptions: FilterOption[] = useMemo(() => {
+ const entries = Object.entries(TEMPLATE_CATEGORY_MAP).filter(([key]) => key !== 'all')
+ return entries.map(([, value]) => ({
+ value,
+ label: getTemplateCategoryText(value),
+ }))
+ }, [getTemplateCategoryText])
+
+ const languageOptions: FilterOption[] = useMemo(() => {
+ return LANGUAGE_OPTIONS.map(lang => ({
+ value: lang.value,
+ label: `${lang.nativeLabel}`,
+ }))
+ }, [])
+
+ return (
+
+ setCategories(v.length ? v : null)}
+ multiple
+ searchable
+ searchPlaceholder={t('searchCategories', { ns: 'plugin' })}
+ />
+ setLanguages(v.length ? v : null)}
+ multiple
+ />
+
+ )
+}
+
+export default TemplateFilters
diff --git a/web/app/components/plugins/marketplace/search-params.ts b/web/app/components/plugins/marketplace/search-params.ts
index 4ef6912ba5..27d448521b 100644
--- a/web/app/components/plugins/marketplace/search-params.ts
+++ b/web/app/components/plugins/marketplace/search-params.ts
@@ -13,6 +13,11 @@ export const marketplaceSearchParamsParsers = {
tags: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }),
creationType: parseAsStringEnum([CREATION_TYPE.plugins, CREATION_TYPE.templates]).withDefault(CREATION_TYPE.plugins).withOptions({ history: 'replace' }),
searchTab: parseAsStringEnum(['all', 'plugins', 'templates', 'creators']).withDefault('').withOptions({ history: 'replace' }),
+ // Search-page-specific filters (independent from list-page category/tags)
+ searchCategories: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }),
+ searchLanguages: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }),
+ searchType: parseAsString.withDefault('all').withOptions({ history: 'replace' }),
+ searchTags: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }),
}
export type SearchTab = 'all' | 'plugins' | 'templates' | 'creators' | ''
diff --git a/web/i18n/en-US/plugin.json b/web/i18n/en-US/plugin.json
index fa0fb5b18f..27035ff2af 100644
--- a/web/i18n/en-US/plugin.json
+++ b/web/i18n/en-US/plugin.json
@@ -220,7 +220,9 @@
"marketplace.searchDropdown.plugins": "Plugins",
"marketplace.searchDropdown.showAllResults": "Show all search results",
"marketplace.searchFilterAll": "All",
+ "marketplace.searchFilterCategory": "Category",
"marketplace.searchFilterCreators": "Creators",
+ "marketplace.searchFilterLanguage": "Supported language",
"marketplace.searchFilterPlugins": "Plugins",
"marketplace.searchFilterTags": "Tags",
"marketplace.searchFilterTypes": "Types",
diff --git a/web/i18n/zh-Hans/plugin.json b/web/i18n/zh-Hans/plugin.json
index d302990209..730efeafb5 100644
--- a/web/i18n/zh-Hans/plugin.json
+++ b/web/i18n/zh-Hans/plugin.json
@@ -220,7 +220,9 @@
"marketplace.searchDropdown.plugins": "插件",
"marketplace.searchDropdown.showAllResults": "显示所有搜索结果",
"marketplace.searchFilterAll": "全部",
+ "marketplace.searchFilterCategory": "分类",
"marketplace.searchFilterCreators": "创作者",
+ "marketplace.searchFilterLanguage": "支持语言",
"marketplace.searchFilterPlugins": "插件",
"marketplace.searchFilterTags": "标签",
"marketplace.searchFilterTypes": "类型",