mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 17:38:04 +08:00
feat: add search filters for categories, languages, types, and tags in marketplace components to enhance user search experience
This commit is contained in:
@ -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.
|
||||
|
||||
@ -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: '한국어' },
|
||||
]
|
||||
@ -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 (
|
||||
<PortalToFollowElem
|
||||
placement="bottom-start"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: 0,
|
||||
}}
|
||||
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-0 rounded-lg px-2 py-1',
|
||||
!hasSelected && 'bg-components-input-bg-normal text-text-tertiary',
|
||||
!hasSelected && open && 'bg-state-base-hover',
|
||||
hasSelected && 'border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs shadow-shadow-shadow-3',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1 p-1">
|
||||
{!hasSelected && (
|
||||
<span className="system-sm-regular text-text-tertiary">{label}</span>
|
||||
)}
|
||||
{hasSelected && (
|
||||
<>
|
||||
<span className="system-sm-regular text-text-tertiary">{label}</span>
|
||||
<span className="system-sm-medium text-text-secondary">
|
||||
{getSelectedLabels()}
|
||||
</span>
|
||||
{value.length > 2 && (
|
||||
<span className="system-xs-medium text-text-tertiary">
|
||||
+
|
||||
{value.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{hasSelected && (
|
||||
<RiCloseCircleFill
|
||||
className="size-4 shrink-0 text-text-quaternary"
|
||||
onClick={handleClear}
|
||||
/>
|
||||
)}
|
||||
{!hasSelected && (
|
||||
<RiArrowDownSLine className="size-4 shrink-0 text-text-tertiary" />
|
||||
)}
|
||||
</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">
|
||||
{searchable && (
|
||||
<div className="p-2 pb-1">
|
||||
<Input
|
||||
showLeftIcon
|
||||
value={searchText}
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
/>
|
||||
</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={() => handleSelect(option.value)}
|
||||
>
|
||||
{multiple && (
|
||||
<Checkbox
|
||||
className="mr-1"
|
||||
checked={value.includes(option.value)}
|
||||
/>
|
||||
)}
|
||||
<div className="system-sm-medium flex-1 px-1 text-text-secondary">
|
||||
{option.label}
|
||||
</div>
|
||||
{!multiple && value.includes(option.value) && (
|
||||
<RiCheckLine className="size-4 text-text-accent" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default FilterChip
|
||||
@ -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"
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between pt-3">
|
||||
<SegmentedControl
|
||||
size="large"
|
||||
activeState="accentLight"
|
||||
value={searchTab}
|
||||
onChange={v => setSearchTab(v as SearchTab)}
|
||||
options={tabOptions}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<SegmentedControl
|
||||
size="large"
|
||||
activeState="accentLight"
|
||||
value={searchTab}
|
||||
onChange={v => setSearchTab(v as SearchTab)}
|
||||
options={tabOptions}
|
||||
/>
|
||||
{searchTab === 'templates' && <TemplateFilters />}
|
||||
{searchTab === 'plugins' && <PluginFilters />}
|
||||
</div>
|
||||
{(searchTab === 'templates' || searchTab === 'plugins') && <SortDropdown />}
|
||||
</div>
|
||||
|
||||
|
||||
@ -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 (
|
||||
<div className="flex items-center gap-2">
|
||||
<FilterChip
|
||||
label={t('marketplace.searchFilterTypes', { ns: 'plugin' })}
|
||||
options={typeOptions}
|
||||
value={typeValue}
|
||||
onChange={(v) => {
|
||||
const newType = v.length > 0 ? v[v.length - 1] : CATEGORY_ALL
|
||||
setSearchType(newType === CATEGORY_ALL ? null : newType)
|
||||
}}
|
||||
multiple={false}
|
||||
/>
|
||||
<FilterChip
|
||||
label={t('marketplace.searchFilterTags', { ns: 'plugin' })}
|
||||
options={tagOptions}
|
||||
value={searchTags}
|
||||
onChange={v => setSearchTags(v.length ? v : null)}
|
||||
multiple
|
||||
searchable
|
||||
searchPlaceholder={t('searchTags', { ns: 'pluginTags' }) || ''}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PluginFilters
|
||||
@ -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 (
|
||||
<div className="flex items-center gap-2">
|
||||
<FilterChip
|
||||
label={t('marketplace.searchFilterCategory', { ns: 'plugin' })}
|
||||
options={categoryOptions}
|
||||
value={categories}
|
||||
onChange={v => setCategories(v.length ? v : null)}
|
||||
multiple
|
||||
searchable
|
||||
searchPlaceholder={t('searchCategories', { ns: 'plugin' })}
|
||||
/>
|
||||
<FilterChip
|
||||
label={t('marketplace.searchFilterLanguage', { ns: 'plugin' })}
|
||||
options={languageOptions}
|
||||
value={languages}
|
||||
onChange={v => setLanguages(v.length ? v : null)}
|
||||
multiple
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TemplateFilters
|
||||
@ -13,6 +13,11 @@ export const marketplaceSearchParamsParsers = {
|
||||
tags: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }),
|
||||
creationType: parseAsStringEnum<CreationType>([CREATION_TYPE.plugins, CREATION_TYPE.templates]).withDefault(CREATION_TYPE.plugins).withOptions({ history: 'replace' }),
|
||||
searchTab: parseAsStringEnum<SearchTab>(['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' | ''
|
||||
|
||||
Reference in New Issue
Block a user