From 0645eaeef93dd351ebb99d936bdace612ea6793f Mon Sep 17 00:00:00 2001 From: Joel Date: Fri, 13 Feb 2026 14:27:00 +0800 Subject: [PATCH] feat: support language filter --- .../components/plugins/marketplace/atoms.ts | 6 + .../category-switch/hero-languages-filter.tsx | 152 ++++++++++++++++++ .../marketplace/category-switch/template.tsx | 39 ++++- .../plugins/marketplace/hydration-server.tsx | 5 +- .../plugins/marketplace/search-params.ts | 1 + .../components/plugins/marketplace/state.ts | 6 +- .../components/plugins/marketplace/utils.ts | 19 +-- 7 files changed, 208 insertions(+), 20 deletions(-) create mode 100644 web/app/components/plugins/marketplace/category-switch/hero-languages-filter.tsx diff --git a/web/app/components/plugins/marketplace/atoms.ts b/web/app/components/plugins/marketplace/atoms.ts index e2fe2604be..9f8ebbd43f 100644 --- a/web/app/components/plugins/marketplace/atoms.ts +++ b/web/app/components/plugins/marketplace/atoms.ts @@ -83,6 +83,10 @@ export function useFilterPluginTags() { return useQueryState('tags', marketplaceSearchParamsParsers.tags) } +export function useFilterTemplateLanguages() { + return useQueryState('languages', marketplaceSearchParamsParsers.languages) +} + export function useSearchTab() { const isAtMarketplace = useAtomValue(isMarketplacePlatformAtom) @@ -151,6 +155,7 @@ export function useMarketplaceSearchMode() { const [searchText] = useSearchText() const [searchTab] = useSearchTab() const [filterPluginTags] = useFilterPluginTags() + const [filterTemplateLanguages] = useFilterTemplateLanguages() const [activePluginCategory] = useActivePluginCategory() const [activeTemplateCategory] = useActiveTemplateCategory() const isPluginsView = creationType === CREATION_TYPE.plugins @@ -160,6 +165,7 @@ export function useMarketplaceSearchMode() { || (isPluginsView && filterPluginTags.length > 0) || (searchMode ?? (isPluginsView && !PLUGIN_CATEGORY_WITH_COLLECTIONS.has(activePluginCategory))) || (!isPluginsView && activeTemplateCategory !== CATEGORY_ALL) + || (!isPluginsView && filterTemplateLanguages.length > 0) return isSearchMode } diff --git a/web/app/components/plugins/marketplace/category-switch/hero-languages-filter.tsx b/web/app/components/plugins/marketplace/category-switch/hero-languages-filter.tsx new file mode 100644 index 0000000000..7ba82b80b1 --- /dev/null +++ b/web/app/components/plugins/marketplace/category-switch/hero-languages-filter.tsx @@ -0,0 +1,152 @@ +'use client' + +import { useTranslation } from '#i18n' +import { RiArrowDownSLine, RiCloseCircleFill, RiGlobalLine } from '@remixicon/react' +import * as React from 'react' +import { useMemo, 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' +import { LANGUAGE_OPTIONS } from '../search-page/constants' + +type HeroLanguagesFilterProps = { + languages: string[] + onLanguagesChange: (languages: string[]) => void +} + +const LANGUAGE_LABEL_MAP: Record = LANGUAGE_OPTIONS.reduce((acc, option) => { + acc[option.value] = option.nativeLabel + return acc +}, {} as Record) + +const HeroLanguagesFilter = ({ + languages, + onLanguagesChange, +}: HeroLanguagesFilterProps) => { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + const [searchText, setSearchText] = useState('') + const selectedLanguagesLength = languages.length + const hasSelected = selectedLanguagesLength > 0 + + const filteredOptions = useMemo(() => { + if (!searchText) + return LANGUAGE_OPTIONS + const normalizedSearchText = searchText.toLowerCase() + return LANGUAGE_OPTIONS.filter(option => + option.nativeLabel.toLowerCase().includes(normalizedSearchText) + || option.label.toLowerCase().includes(normalizedSearchText), + ) + }, [searchText]) + + const handleCheck = (value: string) => { + if (languages.includes(value)) + onLanguagesChange(languages.filter(language => language !== value)) + else + onLanguagesChange([...languages, value]) + } + + return ( + + setOpen(v => !v)} + > +
+ +
+ {!hasSelected && ( + {t('marketplace.searchFilterLanguage', { ns: 'plugin' })} + )} + {hasSelected && ( + + {languages + .map(language => LANGUAGE_LABEL_MAP[language]) + .filter(Boolean) + .slice(0, 2) + .join(', ')} + + )} + {selectedLanguagesLength > 2 && ( +
+ + + + {selectedLanguagesLength - 2} + +
+ )} +
+ {hasSelected && ( + { + e.stopPropagation() + onLanguagesChange([]) + }} + /> + )} + {!hasSelected && ( + + )} +
+
+ +
+
+ setSearchText(e.target.value)} + placeholder={t('marketplace.searchFilterLanguage', { ns: 'plugin' })} + /> +
+
+ {filteredOptions.map(option => ( +
handleCheck(option.value)} + > + +
+ {option.nativeLabel} +
+
+ ))} +
+
+
+
+ ) +} + +export default React.memo(HeroLanguagesFilter) diff --git a/web/app/components/plugins/marketplace/category-switch/template.tsx b/web/app/components/plugins/marketplace/category-switch/template.tsx index d6e7063daa..cbfd867769 100644 --- a/web/app/components/plugins/marketplace/category-switch/template.tsx +++ b/web/app/components/plugins/marketplace/category-switch/template.tsx @@ -1,10 +1,11 @@ 'use client' import { Playground } from '@/app/components/base/icons/src/vender/plugin' -import { useActiveTemplateCategory } from '../atoms' +import { useActiveTemplateCategory, useFilterTemplateLanguages } from '../atoms' import { CATEGORY_ALL, TEMPLATE_CATEGORY_MAP } from '../constants' import { useTemplateCategoryText } from './category-text' import { CommonCategorySwitch } from './common' +import HeroLanguagesFilter from './hero-languages-filter' type TemplateCategorySwitchProps = { className?: string @@ -27,6 +28,7 @@ export const TemplateCategorySwitch = ({ variant = 'default', }: TemplateCategorySwitchProps) => { const [activeTemplateCategory, handleActiveTemplateCategoryChange] = useActiveTemplateCategory() + const [filterTemplateLanguages, setFilterTemplateLanguages] = useFilterTemplateLanguages() const getTemplateCategoryText = useTemplateCategoryText() const isHeroVariant = variant === 'hero' @@ -37,13 +39,34 @@ export const TemplateCategorySwitch = ({ icon: value === CATEGORY_ALL && isHeroVariant ? : null, })) + if (!isHeroVariant) { + return ( + + ) + } + return ( - +
+ setFilterTemplateLanguages(languages.length ? languages : null)} + /> +
+ ยท +
+ +
) } diff --git a/web/app/components/plugins/marketplace/hydration-server.tsx b/web/app/components/plugins/marketplace/hydration-server.tsx index acef5d5d20..4791c00d4b 100644 --- a/web/app/components/plugins/marketplace/hydration-server.tsx +++ b/web/app/components/plugins/marketplace/hydration-server.tsx @@ -183,7 +183,9 @@ async function getDehydratedState( queryFn: () => getMarketplaceTemplateCollectionsAndTemplates(), })) - const isSearchMode = !!parsedSearchParams.q || category !== CATEGORY_ALL + const isSearchMode = !!parsedSearchParams.q + || category !== CATEGORY_ALL + || parsedSearchParams.languages.length > 0 if (isSearchMode) { const templatesParams: TemplateSearchParams = { @@ -191,6 +193,7 @@ async function getDehydratedState( categories: category === CATEGORY_ALL ? undefined : [category], sort_by: DEFAULT_TEMPLATE_SORT.sortBy, sort_order: DEFAULT_TEMPLATE_SORT.sortOrder, + ...(parsedSearchParams.languages.length > 0 ? { languages: parsedSearchParams.languages } : {}), } prefetches.push(queryClient.prefetchInfiniteQuery({ diff --git a/web/app/components/plugins/marketplace/search-params.ts b/web/app/components/plugins/marketplace/search-params.ts index df19224725..2239e67764 100644 --- a/web/app/components/plugins/marketplace/search-params.ts +++ b/web/app/components/plugins/marketplace/search-params.ts @@ -12,6 +12,7 @@ export type SearchTab = (typeof SEARCH_TABS)[number] | '' export const marketplaceSearchParamsParsers = { q: parseAsString.withDefault('').withOptions({ history: 'replace' }), tags: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }), + languages: parseAsArrayOf(parseAsString).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' }), diff --git a/web/app/components/plugins/marketplace/state.ts b/web/app/components/plugins/marketplace/state.ts index 52de55373e..a214e56375 100644 --- a/web/app/components/plugins/marketplace/state.ts +++ b/web/app/components/plugins/marketplace/state.ts @@ -1,7 +1,7 @@ import type { PluginsSearchParams, TemplateSearchParams } from './types' import { useDebounce } from 'ahooks' import { useCallback, useMemo } from 'react' -import { useActivePluginCategory, useActiveTemplateCategory, useCreationType, useFilterPluginTags, useMarketplacePluginSortValue, useMarketplaceSearchMode, useMarketplaceTemplateSortValue, useSearchText } from './atoms' +import { useActivePluginCategory, useActiveTemplateCategory, useCreationType, useFilterPluginTags, useFilterTemplateLanguages, useMarketplacePluginSortValue, useMarketplaceSearchMode, useMarketplaceTemplateSortValue, useSearchText } from './atoms' import { CATEGORY_ALL } from './constants' import { useMarketplaceContainerScroll } from './hooks' import { useMarketplaceCollectionsAndPlugins, useMarketplacePlugins, useMarketplaceTemplateCollectionsAndTemplates, useMarketplaceTemplates } from './query' @@ -75,6 +75,7 @@ export function useTemplatesMarketplaceData(enabled = true) { const [searchTextOriginal] = useSearchText() const searchText = useDebounce(searchTextOriginal, { wait: 500 }) const [activeTemplateCategory] = useActiveTemplateCategory() + const [filterTemplateLanguages] = useFilterTemplateLanguages() // Template collections query (for non-search mode) const templateCollectionsQuery = useMarketplaceTemplateCollectionsAndTemplates(undefined, { enabled }) @@ -94,8 +95,9 @@ export function useTemplatesMarketplaceData(enabled = true) { categories: activeTemplateCategory === CATEGORY_ALL ? undefined : [activeTemplateCategory], sort_by: sort.sortBy, sort_order: sort.sortOrder, + ...(filterTemplateLanguages.length > 0 ? { languages: filterTemplateLanguages } : {}), } - }, [isSearchMode, searchText, activeTemplateCategory, sort]) + }, [isSearchMode, searchText, activeTemplateCategory, sort, filterTemplateLanguages]) // Templates search query (for search mode) const templatesQuery = useMarketplaceTemplates(queryParams, { enabled }) diff --git a/web/app/components/plugins/marketplace/utils.ts b/web/app/components/plugins/marketplace/utils.ts index 938608c4f5..06500d3aa4 100644 --- a/web/app/components/plugins/marketplace/utils.ts +++ b/web/app/components/plugins/marketplace/utils.ts @@ -337,16 +337,17 @@ export const getMarketplaceTemplates = async ( } = queryParams try { + const body = { + page: pageParam, + page_size, + query, + sort_by, + sort_order, + ...(categories ? { categories } : {}), + ...(languages ? { languages } : {}), + } const res = await marketplaceClient.templates.searchAdvanced({ - body: { - page: pageParam, - page_size, - query, - sort_by, - sort_order, - categories, - languages, - }, + body, }, { signal }) return {