feat: support language filter

This commit is contained in:
Joel
2026-02-13 14:27:00 +08:00
parent f49e8954d0
commit 0645eaeef9
7 changed files with 208 additions and 20 deletions

View File

@ -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
}

View File

@ -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<string, string> = LANGUAGE_OPTIONS.reduce((acc, option) => {
acc[option.value] = option.nativeLabel
return acc
}, {} as Record<string, string>)
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 (
<PortalToFollowElem
placement="bottom-start"
offset={{
mainAxis: 4,
crossAxis: -6,
}}
open={open}
onOpenChange={setOpen}
>
<PortalToFollowElemTrigger
className="shrink-0"
onClick={() => setOpen(v => !v)}
>
<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-state-base-hover',
!hasSelected && !open && 'hover:bg-state-base-hover',
hasSelected && 'border-effect-highlight border bg-components-button-secondary-bg-hover shadow-md backdrop-blur-[5px]',
)}
>
<RiGlobalLine
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('marketplace.searchFilterLanguage', { ns: 'plugin' })}</span>
)}
{hasSelected && (
<span className="text-saas-dify-blue-inverted">
{languages
.map(language => LANGUAGE_LABEL_MAP[language])
.filter(Boolean)
.slice(0, 2)
.join(', ')}
</span>
)}
{selectedLanguagesLength > 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">
+
{selectedLanguagesLength - 2}
</span>
</div>
)}
</div>
{hasSelected && (
<RiCloseCircleFill
className="size-4 shrink-0 text-saas-dify-blue-inverted"
onClick={(e) => {
e.stopPropagation()
onLanguagesChange([])
}}
/>
)}
{!hasSelected && (
<RiArrowDownSLine className="size-4 shrink-0 text-text-primary-on-surface" />
)}
</div>
</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('marketplace.searchFilterLanguage', { ns: 'plugin' })}
/>
</div>
<div className="max-h-[448px] overflow-y-auto p-1">
{filteredOptions.map(option => (
<div
key={option.value}
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.value)}
>
<Checkbox
className="mr-1"
checked={languages.includes(option.value)}
/>
<div className="system-sm-medium px-1 text-text-secondary">
{option.nativeLabel}
</div>
</div>
))}
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default React.memo(HeroLanguagesFilter)

View File

@ -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 ? <Playground className="mr-1.5 h-4 w-4" /> : null,
}))
if (!isHeroVariant) {
return (
<CommonCategorySwitch
className={className}
variant={variant}
options={options}
activeValue={activeTemplateCategory}
onChange={handleActiveTemplateCategoryChange}
/>
)
}
return (
<CommonCategorySwitch
className={className}
variant={variant}
options={options}
activeValue={activeTemplateCategory}
onChange={handleActiveTemplateCategoryChange}
/>
<div className="flex shrink-0 items-center gap-2">
<HeroLanguagesFilter
languages={filterTemplateLanguages}
onLanguagesChange={languages => setFilterTemplateLanguages(languages.length ? languages : null)}
/>
<div className="text-text-primary-on-surface">
·
</div>
<CommonCategorySwitch
className={className}
variant={variant}
options={options}
activeValue={activeTemplateCategory}
onChange={handleActiveTemplateCategoryChange}
/>
</div>
)
}

View File

@ -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({

View File

@ -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' }),

View File

@ -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 })

View File

@ -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 {