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": "类型",