feat: implement category switch components for marketplace with hero and default variants

This commit is contained in:
yessenia
2026-02-10 20:57:53 +08:00
parent 56c5739e4e
commit 9f8289b185
7 changed files with 235 additions and 36 deletions

View File

@ -16,7 +16,7 @@ type CategorySwitchProps = {
onChange: (value: string) => void
}
const CategorySwitch = ({
export const CommonCategorySwitch = ({
className,
variant = 'default',
options,
@ -62,5 +62,3 @@ const CategorySwitch = ({
</div>
)
}
export default CategorySwitch

View File

@ -0,0 +1,94 @@
'use client'
import { useTranslation } from '#i18n'
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 { useTags } from '@/app/components/plugins/hooks'
import HeroTagsTrigger from './hero-tags-trigger'
type HeroTagsFilterProps = {
tags: string[]
onTagsChange: (tags: string[]) => void
}
const HeroTagsFilter = ({
tags,
onTagsChange,
}: HeroTagsFilterProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [searchText, setSearchText] = useState('')
const { tags: options, tagsMap } = useTags()
const filteredOptions = options.filter(option => option.label.toLowerCase().includes(searchText.toLowerCase()))
const handleCheck = (id: string) => {
if (tags.includes(id))
onTagsChange(tags.filter((tag: string) => tag !== id))
else
onTagsChange([...tags, id])
}
const selectedTagsLength = tags.length
return (
<PortalToFollowElem
placement="bottom-start"
offset={{
mainAxis: 4,
crossAxis: -6,
}}
open={open}
onOpenChange={setOpen}
>
<PortalToFollowElemTrigger
className="shrink-0"
onClick={() => setOpen(v => !v)}
>
<HeroTagsTrigger
selectedTagsLength={selectedTagsLength}
open={open}
tags={tags}
tagsMap={tagsMap}
onTagsChange={onTagsChange}
/>
</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('searchTags', { ns: 'pluginTags' }) || ''}
/>
</div>
<div className="max-h-[448px] overflow-y-auto p-1">
{
filteredOptions.map(option => (
<div
key={option.name}
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.name)}
>
<Checkbox
className="mr-1"
checked={tags.includes(option.name)}
/>
<div className="system-sm-medium px-1 text-text-secondary">
{option.label}
</div>
</div>
))
}
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default HeroTagsFilter

View File

@ -0,0 +1,86 @@
'use client'
import type { Tag } from '../../hooks'
import { useTranslation } from '#i18n'
import { RiArrowDownSLine, RiCloseCircleFill, RiPriceTag3Line } from '@remixicon/react'
import * as React from 'react'
import { cn } from '@/utils/classnames'
type HeroTagsTriggerProps = {
selectedTagsLength: number
open: boolean
tags: string[]
tagsMap: Record<string, Tag>
onTagsChange: (tags: string[]) => void
}
const HeroTagsTrigger = ({
selectedTagsLength,
open,
tags,
tagsMap,
onTagsChange,
}: HeroTagsTriggerProps) => {
const { t } = useTranslation()
const hasSelected = !!selectedTagsLength
return (
<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-white/10',
!hasSelected && !open && 'hover:bg-white/10',
hasSelected && 'border border-white bg-components-button-secondary-bg-hover shadow-md backdrop-blur-[5px]',
)}
>
<RiPriceTag3Line 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('allTags', { ns: 'pluginTags' })}</span>
)
}
{
hasSelected && (
<span className="text-saas-dify-blue-inverted">
{tags.map(tag => tagsMap[tag]?.label).filter(Boolean).slice(0, 2).join(', ')}
</span>
)
}
{
selectedTagsLength > 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">
+
{selectedTagsLength - 2}
</span>
</div>
)
}
</div>
{
hasSelected && (
<RiCloseCircleFill
className="size-4 shrink-0 text-saas-dify-blue-inverted"
onClick={(e) => {
e.stopPropagation()
onTagsChange([])
}}
/>
)
}
{
!hasSelected && (
<RiArrowDownSLine className="size-4 shrink-0 text-text-primary-on-surface" />
)
}
</div>
)
}
export default React.memo(HeroTagsTrigger)

View File

@ -0,0 +1,4 @@
'use client'
export { PluginCategorySwitch } from './plugin'
export { TemplateCategorySwitch } from './template'

View File

@ -1,15 +1,16 @@
'use client'
import type { ActivePluginType } from './constants'
import type { ActivePluginType } from '../constants'
import type { PluginCategoryEnum } from '@/app/components/plugins/types'
import { useTranslation } from '#i18n'
import { RiArchive2Line } from '@remixicon/react'
import { useSetAtom } from 'jotai'
import { Plugin } from '@/app/components/base/icons/src/vender/plugin'
import { searchModeAtom, useActivePluginCategory } from './atoms'
import CategorySwitch from './category-switch'
import { PLUGIN_CATEGORY_WITH_COLLECTIONS, PLUGIN_TYPE_SEARCH_MAP } from './constants'
import { MARKETPLACE_TYPE_ICON_COMPONENTS } from './plugin-type-icons'
import { searchModeAtom, useActivePluginCategory, useFilterPluginTags } from '../atoms'
import { PLUGIN_CATEGORY_WITH_COLLECTIONS, PLUGIN_TYPE_SEARCH_MAP } from '../constants'
import { MARKETPLACE_TYPE_ICON_COMPONENTS } from '../plugin-type-icons'
import { CommonCategorySwitch } from './common'
import HeroTagsFilter from './hero-tags-filter'
type PluginTypeSwitchProps = {
className?: string
@ -25,12 +26,13 @@ const getTypeIcon = (value: ActivePluginType, isHeroVariant?: boolean) => {
return Icon ? <Icon className="mr-1.5 h-4 w-4" /> : null
}
const PluginCategorySwitch = ({
export const PluginCategorySwitch = ({
className,
variant = 'default',
}: PluginTypeSwitchProps) => {
const { t } = useTranslation()
const [activePluginCategory, handleActivePluginCategoryChange] = useActivePluginCategory()
const [filterPluginTags, setFilterPluginTags] = useFilterPluginTags()
const setSearchMode = useSetAtom(searchModeAtom)
const isHeroVariant = variant === 'hero'
@ -39,42 +41,42 @@ const PluginCategorySwitch = ({
{
value: PLUGIN_TYPE_SEARCH_MAP.all,
text: isHeroVariant ? t('category.allTypes', { ns: 'plugin' }) : t('category.all', { ns: 'plugin' }),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.all),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.all, isHeroVariant),
},
{
value: PLUGIN_TYPE_SEARCH_MAP.model,
text: t('category.models', { ns: 'plugin' }),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.model),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.model, isHeroVariant),
},
{
value: PLUGIN_TYPE_SEARCH_MAP.tool,
text: t('category.tools', { ns: 'plugin' }),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.tool),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.tool, isHeroVariant),
},
{
value: PLUGIN_TYPE_SEARCH_MAP.datasource,
text: t('category.datasources', { ns: 'plugin' }),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.datasource),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.datasource, isHeroVariant),
},
{
value: PLUGIN_TYPE_SEARCH_MAP.trigger,
text: t('category.triggers', { ns: 'plugin' }),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.trigger),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.trigger, isHeroVariant),
},
{
value: PLUGIN_TYPE_SEARCH_MAP.agent,
text: t('category.agents', { ns: 'plugin' }),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.agent),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.agent, isHeroVariant),
},
{
value: PLUGIN_TYPE_SEARCH_MAP.extension,
text: t('category.extensions', { ns: 'plugin' }),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.extension),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.extension, isHeroVariant),
},
{
value: PLUGIN_TYPE_SEARCH_MAP.bundle,
text: t('category.bundles', { ns: 'plugin' }),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.bundle),
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.bundle, isHeroVariant),
},
]
@ -85,15 +87,34 @@ const PluginCategorySwitch = ({
}
}
if (!isHeroVariant) {
return (
<CommonCategorySwitch
className={className}
variant={variant}
options={options}
activeValue={activePluginCategory}
onChange={handleChange}
/>
)
}
return (
<CategorySwitch
className={className}
variant={variant}
options={options}
activeValue={activePluginCategory}
onChange={handleChange}
/>
<div className="flex shrink-0 items-center gap-2">
<HeroTagsFilter
tags={filterPluginTags}
onTagsChange={tags => setFilterPluginTags(tags.length ? tags : null)}
/>
<div className="text-text-primary-on-surface">
·
</div>
<CommonCategorySwitch
className={className}
variant={variant}
options={options}
activeValue={activePluginCategory}
onChange={handleChange}
/>
</div>
)
}
export default PluginCategorySwitch

View File

@ -1,18 +1,17 @@
'use client'
import { useTranslation } from '#i18n'
import { RiFileList3Line } from '@remixicon/react'
import { useActiveTemplateCategory } from './atoms'
import CategorySwitch from './category-switch'
import { CATEGORY_ALL, TEMPLATE_CATEGORY_MAP } from './constants'
import { Playground } from '@/app/components/base/icons/src/vender/plugin'
import { useActiveTemplateCategory } from '../atoms'
import { CATEGORY_ALL, TEMPLATE_CATEGORY_MAP } from '../constants'
import { CommonCategorySwitch } from './common'
type TemplateCategorySwitchProps = {
className?: string
variant?: 'default' | 'hero'
}
const TemplateCategorySwitch = ({
export const TemplateCategorySwitch = ({
className,
variant = 'default',
}: TemplateCategorySwitchProps) => {
@ -65,7 +64,7 @@ const TemplateCategorySwitch = ({
]
return (
<CategorySwitch
<CommonCategorySwitch
className={className}
variant={variant}
options={options}
@ -74,5 +73,3 @@ const TemplateCategorySwitch = ({
/>
)
}
export default TemplateCategorySwitch

View File

@ -7,8 +7,7 @@ import { useEffect, useLayoutEffect, useRef } from 'react'
import marketPlaceBg from '@/public/marketplace/hero-bg.jpg'
import marketplaceGradientNoise from '@/public/marketplace/hero-gradient-noise.svg'
import { cn } from '@/utils/classnames'
import PluginCategorySwitch from '../plugin-category-switch'
import TemplateCategorySwitch from '../template-category-switch'
import { PluginCategorySwitch, TemplateCategorySwitch } from '../category-switch/index'
import { useMarketplaceData } from '../state'
type DescriptionProps = {