feat: add search filters for categories, languages, types, and tags in marketplace components to enhance user search experience

This commit is contained in:
yessenia
2026-02-11 12:35:13 +08:00
parent 7b41fc4d64
commit 5f6f9ed517
9 changed files with 373 additions and 12 deletions

View File

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

View File

@ -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: '한국어' },
]

View File

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

View File

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

View File

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

View File

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

View File

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