mirror of
https://github.com/langgenius/dify.git
synced 2026-02-26 20:47:19 +08:00
feat: support language filter
This commit is contained in:
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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' }),
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user