Compare commits

...

1 Commits

Author SHA1 Message Date
yessenia
53252d0395 feat: marketplace layout opt 2026-02-03 16:39:16 +08:00
17 changed files with 548 additions and 170 deletions

View File

@@ -6,7 +6,7 @@ const PluginList = () => {
return (
<PluginPage
plugins={<PluginsPanel />}
marketplace={<Marketplace pluginTypeSwitchClassName="top-[60px]" />}
marketplace={<Marketplace />}
/>
)
}

View File

@@ -1,4 +1,4 @@
import { RiInstallLine } from '@remixicon/react'
import { useTranslation } from '#i18n'
import * as React from 'react'
import { formatNumber } from '@/utils/format'
@@ -9,10 +9,13 @@ type Props = {
const DownloadCountComponent = ({
downloadCount,
}: Props) => {
const { t } = useTranslation('plugin')
return (
<div className="flex items-center space-x-1 text-text-tertiary">
<RiInstallLine className="h-3 w-3 shrink-0" />
<div className="system-xs-regular">{formatNumber(downloadCount)}</div>
<div className="system-xs-regular text-text-tertiary">
{formatNumber(downloadCount)}
{' '}
{t('marketplace.installs')}
</div>
)
}

View File

@@ -1,10 +1,12 @@
import { cn } from '@/utils/classnames'
import DownloadCount from './download-count'
type Props = {
className?: string
orgName?: string
packageName: string
packageName?: string
packageNameClassName?: string
downloadCount?: number
}
const OrgInfo = ({
@@ -12,7 +14,25 @@ const OrgInfo = ({
orgName,
packageName,
packageNameClassName,
downloadCount,
}: Props) => {
// New format: "by {orgName} · {downloadCount} installs" (for marketplace cards)
if (downloadCount !== undefined) {
return (
<div className={cn('system-xs-regular flex h-4 items-center gap-2 text-text-tertiary', className)}>
{orgName && (
<span className="shrink-0">
by
{orgName}
</span>
)}
<span className="shrink-0">·</span>
<DownloadCount downloadCount={downloadCount} />
</div>
)
}
// Legacy format: "{orgName} / {packageName}" (for plugin detail panels)
return (
<div className={cn('flex h-4 items-center space-x-0.5', className)}>
{orgName && (
@@ -21,9 +41,11 @@ const OrgInfo = ({
<span className="system-xs-regular shrink-0 text-text-quaternary">/</span>
</>
)}
<span className={cn('system-xs-regular w-0 shrink-0 grow truncate text-text-tertiary', packageNameClassName)}>
{packageName}
</span>
{packageName && (
<span className={cn('system-xs-regular w-0 shrink-0 grow truncate text-text-tertiary', packageNameClassName)}>
{packageName}
</span>
)}
</div>
)
}

View File

@@ -1,34 +1,28 @@
import { RiPriceTag3Line } from '@remixicon/react'
import * as React from 'react'
import DownloadCount from './base/download-count'
type Props = {
downloadCount?: number
tags: string[]
}
const CardMoreInfoComponent = ({
downloadCount,
tags,
}: Props) => {
return (
<div className="flex h-5 items-center">
{downloadCount !== undefined && <DownloadCount downloadCount={downloadCount} />}
{downloadCount !== undefined && tags && tags.length > 0 && <div className="system-xs-regular mx-2 text-text-quaternary">·</div>}
<div className="mt-2 flex min-h-[20px] items-center gap-1">
{tags && tags.length > 0 && (
<>
<div className="flex h-4 flex-wrap space-x-2 overflow-hidden">
{tags.map(tag => (
<div
key={tag}
className="system-xs-regular flex max-w-[120px] space-x-1 overflow-hidden"
title={`# ${tag}`}
>
<span className="text-text-quaternary">#</span>
<span className="truncate text-text-tertiary">{tag}</span>
</div>
))}
</div>
</>
<div className="flex flex-wrap gap-1 overflow-hidden">
{tags.slice(0, 2).map(tag => (
<span
key={tag}
className="inline-flex max-w-[100px] items-center gap-0.5 truncate rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-[5px] py-[3px]"
title={tag}
>
<RiPriceTag3Line className="h-3 w-3 shrink-0 text-text-quaternary" />
<span className="system-2xs-medium-uppercase text-text-tertiary">{tag.toUpperCase()}</span>
</span>
))}
</div>
)}
</div>
)

View File

@@ -50,7 +50,7 @@ const Card = ({
const locale = useGetLanguage()
const { t } = useTranslation()
const { categoriesMap } = useCategories(true)
const { category, type, name, org, label, brief, icon, icon_dark, verified, badges = [] } = payload
const { category, type, org, label, brief, icon, icon_dark, verified, badges = [], install_count } = payload
const { theme } = useTheme()
const iconSrc = theme === Theme.dark && icon_dark ? icon_dark : icon
const getLocalizedText = (obj: Record<string, string> | undefined) =>
@@ -86,7 +86,7 @@ const Card = ({
<OrgInfo
className="mt-0.5"
orgName={org}
packageName={name}
downloadCount={install_count}
/>
</div>
</div>

View File

@@ -0,0 +1,83 @@
'use client'
// todo: update the illustration
const HeroIllustration = () => {
return (
<svg
width="280"
height="160"
viewBox="0 0 280 160"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="absolute right-0 top-1/2 -translate-y-1/2 opacity-80"
>
{/* Large circle - top right */}
<circle
cx="220"
cy="40"
r="60"
fill="url(#gradient1)"
fillOpacity="0.3"
/>
{/* Medium circle - middle */}
<circle
cx="180"
cy="100"
r="40"
fill="url(#gradient2)"
fillOpacity="0.4"
/>
{/* Small circle - bottom */}
<circle
cx="240"
cy="120"
r="25"
fill="url(#gradient3)"
fillOpacity="0.5"
/>
{/* Decorative dots */}
<circle cx="140" cy="60" r="4" fill="white" fillOpacity="0.6" />
<circle cx="160" cy="45" r="3" fill="white" fillOpacity="0.4" />
<circle cx="130" cy="90" r="5" fill="white" fillOpacity="0.5" />
<circle cx="200" cy="70" r="3" fill="white" fillOpacity="0.3" />
{/* Abstract shapes */}
<rect
x="150"
y="110"
width="30"
height="30"
rx="8"
fill="white"
fillOpacity="0.15"
transform="rotate(-15 150 110)"
/>
<rect
x="100"
y="50"
width="20"
height="20"
rx="4"
fill="white"
fillOpacity="0.1"
transform="rotate(10 100 50)"
/>
{/* Gradient definitions */}
<defs>
<radialGradient id="gradient1" cx="0.5" cy="0.5" r="0.5">
<stop offset="0%" stopColor="white" stopOpacity="0.6" />
<stop offset="100%" stopColor="white" stopOpacity="0" />
</radialGradient>
<radialGradient id="gradient2" cx="0.5" cy="0.5" r="0.5">
<stop offset="0%" stopColor="white" stopOpacity="0.5" />
<stop offset="100%" stopColor="white" stopOpacity="0" />
</radialGradient>
<radialGradient id="gradient3" cx="0.5" cy="0.5" r="0.5">
<stop offset="0%" stopColor="white" stopOpacity="0.7" />
<stop offset="100%" stopColor="white" stopOpacity="0" />
</radialGradient>
</defs>
</svg>
)
}
export default HeroIllustration

View File

@@ -1,72 +1,36 @@
import { useLocale, useTranslation } from '#i18n'
'use client'
const Description = () => {
const { t } = useTranslation('plugin')
const { t: tCommon } = useTranslation('common')
const locale = useLocale()
import { useTranslation } from '#i18n'
import { cn } from '@/utils/classnames'
import PluginTypeSwitch from '../plugin-type-switch'
import HeroIllustration from './hero-illustration'
const isZhHans = locale === 'zh-Hans'
return (
<>
<h1 className="title-4xl-semi-bold mb-2 shrink-0 text-center text-text-primary">
{t('marketplace.empower')}
</h1>
<h2 className="body-md-regular flex shrink-0 items-center justify-center text-center text-text-tertiary">
{
isZhHans && (
<>
<span className="mr-1">{tCommon('operation.in')}</span>
{t('marketplace.difyMarketplace')}
{t('marketplace.discover')}
</>
)
}
{
!isZhHans && (
<>
{t('marketplace.discover')}
</>
)
}
<span className="body-md-medium relative z-[1] ml-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
{t('category.models')}
</span>
,
<span className="body-md-medium relative z-[1] ml-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
{t('category.tools')}
</span>
,
<span className="body-md-medium relative z-[1] ml-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
{t('category.datasources')}
</span>
,
<span className="body-md-medium relative z-[1] ml-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
{t('category.triggers')}
</span>
,
<span className="body-md-medium relative z-[1] ml-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
{t('category.agents')}
</span>
,
<span className="body-md-medium relative z-[1] ml-1 mr-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
{t('category.extensions')}
</span>
{t('marketplace.and')}
<span className="body-md-medium relative z-[1] ml-1 mr-1 text-text-secondary after:absolute after:bottom-[1.5px] after:left-0 after:h-2 after:w-full after:bg-text-text-selected after:content-['']">
{t('category.bundles')}
</span>
{
!isZhHans && (
<>
<span className="mr-1">{tCommon('operation.in')}</span>
{t('marketplace.difyMarketplace')}
</>
)
}
</h2>
</>
)
type DescriptionProps = {
className?: string
}
export default Description
export const Description = ({ className }: DescriptionProps) => {
const { t } = useTranslation('plugin')
return (
<div className={cn('relative mx-4 mt-4 h-[200px] rounded-2xl bg-gradient-to-r from-util-colors-blue-brand-blue-brand-600 to-util-colors-blue-brand-blue-brand-500 px-8 py-6', className)}>
{/* Background illustration */}
<HeroIllustration />
{/* Content */}
<div className="relative z-10">
<h1 className="title-4xl-semi-bold mb-2 shrink-0 text-text-primary-on-surface">
{t('marketplace.heroTitle')}
</h1>
<h2 className="body-md-regular shrink-0 text-text-secondary-on-surface">
{t('marketplace.heroSubtitle')}
</h2>
{/* Plugin type switch tabs */}
<div className="mt-6">
<PluginTypeSwitch variant="hero" />
</div>
</div>
</div>
)
}

View File

@@ -1,13 +1,11 @@
import type { SearchParams } from 'nuqs'
import { TanstackQueryInitializer } from '@/context/query-client'
import Description from './description'
import { Description } from './description'
import { HydrateQueryClient } from './hydration-server'
import ListWrapper from './list/list-wrapper'
import StickySearchAndSwitchWrapper from './sticky-search-and-switch-wrapper'
type MarketplaceProps = {
showInstallButton?: boolean
pluginTypeSwitchClassName?: string
/**
* Pass the search params from the request to prefetch data on the server.
*/
@@ -16,16 +14,12 @@ type MarketplaceProps = {
const Marketplace = async ({
showInstallButton = true,
pluginTypeSwitchClassName,
searchParams,
}: MarketplaceProps) => {
return (
<TanstackQueryInitializer>
<HydrateQueryClient searchParams={searchParams}>
<Description />
<StickySearchAndSwitchWrapper
pluginTypeSwitchClassName={pluginTypeSwitchClassName}
/>
<Description className="mx-12 mt-1" />
<ListWrapper
showInstallButton={showInstallButton}
/>

View File

@@ -50,7 +50,6 @@ const CardWrapperComponent = ({
payload={plugin}
footer={(
<CardMoreInfo
downloadCount={plugin.install_count}
tags={tagLabels}
/>
)}
@@ -88,7 +87,7 @@ const CardWrapperComponent = ({
return (
<a
className="group relative inline-block cursor-pointer rounded-xl"
className="group relative block cursor-pointer rounded-xl"
href={getPluginDetailLinkInMarketplace(plugin)}
>
<Card
@@ -96,7 +95,6 @@ const CardWrapperComponent = ({
payload={plugin}
footer={(
<CardMoreInfo
downloadCount={plugin.install_count}
tags={tagLabels}
/>
)}

View File

@@ -0,0 +1,238 @@
'use client'
import type { RemixiconComponentType } from '@remixicon/react'
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
import { cn } from '@/utils/classnames'
type CarouselProps = {
children: React.ReactNode
className?: string
itemWidth?: number
gap?: number
showNavigation?: boolean
showPagination?: boolean
autoPlay?: boolean
autoPlayInterval?: number
}
type ScrollState = {
canScrollLeft: boolean
canScrollRight: boolean
currentPage: number
totalPages: number
}
const defaultScrollState: ScrollState = {
canScrollLeft: false,
canScrollRight: false,
currentPage: 0,
totalPages: 0,
}
type NavButtonProps = {
direction: 'left' | 'right'
disabled: boolean
onClick: () => void
Icon: RemixiconComponentType
}
const NavButton = ({ direction, disabled, onClick, Icon }: NavButtonProps) => (
<button
className={cn(
'flex items-center justify-center rounded-full border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg p-2 shadow-xs backdrop-blur-[5px] transition-all',
disabled
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer hover:bg-components-button-secondary-bg-hover',
)}
onClick={onClick}
disabled={disabled}
aria-label={`Scroll ${direction}`}
>
<Icon className="h-4 w-4 text-components-button-secondary-text" />
</button>
)
const Carousel = ({
children,
className,
itemWidth = 280,
gap = 12,
showNavigation = true,
showPagination = true,
autoPlay = false,
autoPlayInterval = 5000,
}: CarouselProps) => {
const containerRef = useRef<HTMLDivElement>(null)
const scrollStateRef = useRef<ScrollState>(defaultScrollState)
const [isHovered, setIsHovered] = useState(false)
const calculateScrollState = useCallback((container: HTMLDivElement): ScrollState => {
const { scrollLeft, scrollWidth, clientWidth } = container
const canScrollLeft = scrollLeft > 0
const canScrollRight = scrollLeft < scrollWidth - clientWidth - 1
// Calculate total pages based on actual scroll range
const maxScrollLeft = scrollWidth - clientWidth
const itemsPerPage = Math.floor(clientWidth / (itemWidth + gap))
const totalItems = container.children.length
const pages = Math.max(1, Math.ceil(totalItems / itemsPerPage))
// Calculate current page based on scroll position ratio
let currentPage = 0
if (maxScrollLeft > 0) {
const scrollRatio = scrollLeft / maxScrollLeft
currentPage = Math.round(scrollRatio * (pages - 1))
}
return {
canScrollLeft,
canScrollRight,
totalPages: pages,
currentPage: Math.min(Math.max(0, currentPage), pages - 1),
}
}, [itemWidth, gap])
const subscribe = useCallback((onStoreChange: () => void) => {
const container = containerRef.current
if (!container)
return () => { }
const handleChange = () => {
scrollStateRef.current = calculateScrollState(container)
onStoreChange()
}
// Initial calculation
handleChange()
const resizeObserver = new ResizeObserver(handleChange)
resizeObserver.observe(container)
container.addEventListener('scroll', handleChange)
return () => {
resizeObserver.disconnect()
container.removeEventListener('scroll', handleChange)
}
}, [calculateScrollState])
const getSnapshot = useCallback(() => scrollStateRef.current, [])
const scrollState = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
// Re-subscribe when children change
useEffect(() => {
const container = containerRef.current
if (container)
scrollStateRef.current = calculateScrollState(container)
}, [children, calculateScrollState])
const scroll = useCallback((direction: 'left' | 'right') => {
const container = containerRef.current
if (!container)
return
const scrollAmount = container.clientWidth - (itemWidth / 2)
const newScrollLeft = direction === 'left'
? container.scrollLeft - scrollAmount
: container.scrollLeft + scrollAmount
container.scrollTo({
left: newScrollLeft,
behavior: 'smooth',
})
}, [itemWidth])
const scrollToPage = useCallback((pageIndex: number) => {
const container = containerRef.current
if (!container)
return
const itemsPerPage = Math.floor(container.clientWidth / (itemWidth + gap))
const scrollLeft = pageIndex * itemsPerPage * (itemWidth + gap)
container.scrollTo({
left: scrollLeft,
behavior: 'smooth',
})
}, [itemWidth, gap])
// Auto-play functionality
useEffect(() => {
if (!autoPlay || isHovered || scrollState.totalPages <= 1)
return
const interval = setInterval(() => {
const nextPage = scrollState.canScrollRight
? scrollState.currentPage + 1
: 0 // Loop back to first page
scrollToPage(nextPage)
}, autoPlayInterval)
return () => clearInterval(interval)
}, [autoPlay, autoPlayInterval, isHovered, scrollState.totalPages, scrollState.canScrollRight, scrollState.currentPage, scrollToPage])
const handleMouseEnter = useCallback(() => setIsHovered(true), [])
const handleMouseLeave = useCallback(() => setIsHovered(false), [])
return (
<div
className={cn('relative', className)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{/* Navigation arrows */}
{showNavigation && (
<div className="absolute -top-10 right-0 flex items-center gap-3">
{/* Pagination dots */}
{showPagination && scrollState.totalPages > 1 && (
<div className="flex items-center gap-1">
{Array.from({ length: scrollState.totalPages }).map((_, index) => (
<button
key={index}
className={cn(
'h-[5px] w-[5px] rounded-full transition-all',
scrollState.currentPage === index
? 'w-4 bg-components-button-primary-bg'
: 'bg-components-button-secondary-border hover:bg-components-button-secondary-border-hover',
)}
onClick={() => scrollToPage(index)}
aria-label={`Go to page ${index + 1}`}
/>
))}
</div>
)}
<div className="flex items-center gap-1">
<NavButton
direction="left"
disabled={!scrollState.canScrollLeft}
onClick={() => scroll('left')}
Icon={RiArrowLeftSLine}
/>
<NavButton
direction="right"
disabled={!scrollState.canScrollRight}
onClick={() => scroll('right')}
Icon={RiArrowRightSLine}
/>
</div>
</div>
)}
{/* Scrollable container */}
<div
ref={containerRef}
className="no-scrollbar flex gap-3 overflow-x-auto scroll-smooth"
style={{
scrollSnapType: 'x mandatory',
WebkitOverflowScrolling: 'touch',
}}
>
{children}
</div>
</div>
)
}
export default Carousel

View File

@@ -5,9 +5,9 @@ import type { Plugin } from '@/app/components/plugins/types'
import { useLocale, useTranslation } from '#i18n'
import { RiArrowRightSLine } from '@remixicon/react'
import { getLanguage } from '@/i18n-config/language'
import { cn } from '@/utils/classnames'
import { useMarketplaceMoreClick } from '../atoms'
import CardWrapper from './card-wrapper'
import Carousel from './carousel'
type ListWithCollectionProps = {
marketplaceCollections: MarketplaceCollection[]
@@ -16,6 +16,10 @@ type ListWithCollectionProps = {
cardContainerClassName?: string
cardRender?: (plugin: Plugin) => React.JSX.Element | null
}
const PARTNERS_COLLECTION_NAME = 'partners'
const GRID_DISPLAY_LIMIT = 8 // 2 rows × 4 columns
const ListWithCollection = ({
marketplaceCollections,
marketplaceCollectionPluginsMap,
@@ -27,55 +31,102 @@ const ListWithCollection = ({
const locale = useLocale()
const onMoreClick = useMarketplaceMoreClick()
const renderPluginCard = (plugin: Plugin) => {
if (cardRender)
return cardRender(plugin)
return (
<CardWrapper
plugin={plugin}
showInstallButton={showInstallButton}
/>
)
}
const renderPartnersCarousel = (collection: MarketplaceCollection, plugins: Plugin[]) => {
// Partners collection: 2-row carousel with auto-play
const rows: Plugin[][] = []
for (let i = 0; i < plugins.length; i += 2) {
// Group plugins in pairs (2 per column)
rows.push(plugins.slice(i, i + 2))
}
return (
<Carousel
className={cardContainerClassName}
showNavigation={plugins.length > 8}
showPagination={plugins.length > 8}
autoPlay={plugins.length > 8}
autoPlayInterval={5000}
>
{rows.map(columnPlugins => (
<div
key={`column-${columnPlugins[0]?.plugin_id}`}
className="flex shrink-0 flex-col gap-3"
style={{ scrollSnapAlign: 'start', width: 'calc((100% - 36px) / 4)' }}
>
{columnPlugins.map(plugin => (
<div key={plugin.plugin_id}>
{renderPluginCard(plugin)}
</div>
))}
</div>
))}
</Carousel>
)
}
const renderGridCollection = (collection: MarketplaceCollection, plugins: Plugin[]) => {
// Other collections: Fixed 2 rows × 4 columns grid
const displayPlugins = plugins.slice(0, GRID_DISPLAY_LIMIT)
return (
<div className="grid grid-cols-4 gap-3">
{displayPlugins.map(plugin => (
<div key={plugin.plugin_id}>
{renderPluginCard(plugin)}
</div>
))}
</div>
)
}
return (
<>
{
marketplaceCollections.filter((collection) => {
return marketplaceCollectionPluginsMap[collection.name]?.length
}).map(collection => (
<div
key={collection.name}
className="py-3"
>
<div className="flex items-end justify-between">
<div>
<div className="title-xl-semi-bold text-text-primary">{collection.label[getLanguage(locale)]}</div>
<div className="system-xs-regular text-text-tertiary">{collection.description[getLanguage(locale)]}</div>
</div>
{
collection.searchable && (
}).map((collection) => {
const plugins = marketplaceCollectionPluginsMap[collection.name]
const isPartnersCollection = collection.name === PARTNERS_COLLECTION_NAME
const showViewMore = collection.searchable && (isPartnersCollection || plugins.length > GRID_DISPLAY_LIMIT)
return (
<div
key={collection.name}
className="py-3"
>
<div className="mb-2 flex items-end justify-between">
<div>
<div className="title-xl-semi-bold text-text-primary">{collection.label[getLanguage(locale)]}</div>
<div className="system-xs-regular text-text-tertiary">{collection.description[getLanguage(locale)]}</div>
</div>
{showViewMore && (
<div
className="system-xs-medium flex cursor-pointer items-center text-text-accent "
className="system-xs-medium flex cursor-pointer items-center text-text-accent"
onClick={() => onMoreClick(collection.search_params)}
>
{t('marketplace.viewMore', { ns: 'plugin' })}
<RiArrowRightSLine className="h-4 w-4" />
</div>
)
}
)}
</div>
{isPartnersCollection
? renderPartnersCarousel(collection, plugins)
: renderGridCollection(collection, plugins)}
</div>
<div className={cn(
'mt-2 grid grid-cols-4 gap-3',
cardContainerClassName,
)}
>
{
marketplaceCollectionPluginsMap[collection.name].map((plugin) => {
if (cardRender)
return cardRender(plugin)
return (
<CardWrapper
key={plugin.plugin_id}
plugin={plugin}
showInstallButton={showInstallButton}
/>
)
})
}
</div>
</div>
))
)
})
}
</>
)

View File

@@ -2,6 +2,7 @@
import type { ActivePluginType } from './constants'
import { useTranslation } from '#i18n'
import {
RiApps2Line,
RiArchive2Line,
RiBrain2Line,
RiDatabase2Line,
@@ -17,14 +18,18 @@ import { PLUGIN_CATEGORY_WITH_COLLECTIONS, PLUGIN_TYPE_SEARCH_MAP } from './cons
type PluginTypeSwitchProps = {
className?: string
variant?: 'default' | 'hero'
}
const PluginTypeSwitch = ({
className,
variant = 'default',
}: PluginTypeSwitchProps) => {
const { t } = useTranslation()
const [activePluginType, handleActivePluginTypeChange] = useActivePluginType()
const setSearchMode = useSetAtom(searchModeAtom)
const isHeroVariant = variant === 'hero'
const options: Array<{
value: ActivePluginType
text: string
@@ -32,8 +37,8 @@ const PluginTypeSwitch = ({
}> = [
{
value: PLUGIN_TYPE_SEARCH_MAP.all,
text: t('category.all', { ns: 'plugin' }),
icon: null,
text: isHeroVariant ? t('category.allTypes', { ns: 'plugin' }) : t('category.all', { ns: 'plugin' }),
icon: isHeroVariant ? <RiApps2Line className="mr-1.5 h-4 w-4" /> : null,
},
{
value: PLUGIN_TYPE_SEARCH_MAP.model,
@@ -72,9 +77,25 @@ const PluginTypeSwitch = ({
},
]
const getItemClassName = (isActive: boolean) => {
if (isHeroVariant) {
return cn(
'system-md-medium flex h-8 cursor-pointer items-center rounded-lg px-3 text-text-primary-on-surface transition-all',
isActive
? 'bg-components-button-secondary-bg text-saas-dify-blue-inverted'
: 'hover:bg-state-base-hover',
)
}
return cn(
'system-md-medium flex h-8 cursor-pointer items-center rounded-xl border border-transparent px-3 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
isActive && 'border-components-main-nav-nav-button-border !bg-components-main-nav-nav-button-bg-active !text-components-main-nav-nav-button-text-active shadow-xs',
)
}
return (
<div className={cn(
'flex shrink-0 items-center justify-center space-x-2 bg-background-body py-3',
'flex shrink-0 items-center space-x-2',
!isHeroVariant && 'justify-center bg-background-body py-3',
className,
)}
>
@@ -82,10 +103,7 @@ const PluginTypeSwitch = ({
options.map(option => (
<div
key={option.value}
className={cn(
'system-md-medium flex h-8 cursor-pointer items-center rounded-xl border border-transparent px-3 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
activePluginType === option.value && 'border-components-main-nav-nav-button-border !bg-components-main-nav-nav-button-bg-active !text-components-main-nav-nav-button-text-active shadow-xs',
)}
className={getItemClassName(activePluginType === option.value)}
onClick={() => {
handleActivePluginTypeChange(option.value)
if (PLUGIN_CATEGORY_WITH_COLLECTIONS.has(option.value)) {

View File

@@ -1,18 +1,26 @@
'use client'
import { useTranslation } from '#i18n'
import { cn } from '@/utils/classnames'
import { useFilterPluginTags, useSearchPluginText } from '../atoms'
import SearchBox from './index'
const SearchBoxWrapper = () => {
type SearchBoxWrapperProps = {
wrapperClassName?: string
inputClassName?: string
}
const SearchBoxWrapper = ({
wrapperClassName,
inputClassName,
}: SearchBoxWrapperProps) => {
const { t } = useTranslation()
const [searchPluginText, handleSearchPluginTextChange] = useSearchPluginText()
const [filterPluginTags, handleFilterPluginTagsChange] = useFilterPluginTags()
return (
<SearchBox
wrapperClassName="z-[11] mx-auto w-[640px] shrink-0"
inputClassName="w-full"
wrapperClassName={cn('z-[11] mx-auto w-[640px] shrink-0', wrapperClassName)}
inputClassName={cn('w-full', inputClassName)}
search={searchPluginText}
onSearchChange={handleSearchPluginTextChange}
tags={filterPluginTags}

View File

@@ -1,7 +1,6 @@
'use client'
import { cn } from '@/utils/classnames'
import PluginTypeSwitch from './plugin-type-switch'
import SearchBoxWrapper from './search-box/search-box-wrapper'
type StickySearchAndSwitchWrapperProps = {
@@ -22,7 +21,6 @@ const StickySearchAndSwitchWrapper = ({
)}
>
<SearchBoxWrapper />
<PluginTypeSwitch />
</div>
)
}

View File

@@ -27,6 +27,7 @@ import { PLUGIN_PAGE_TABS_MAP } from '../hooks'
import InstallFromLocalPackage from '../install-plugin/install-from-local-package'
import InstallFromMarketplace from '../install-plugin/install-from-marketplace'
import { PLUGIN_TYPE_SEARCH_MAP } from '../marketplace/constants'
import SearchBoxWrapper from '../marketplace/search-box/search-box-wrapper'
import {
PluginPageContextProvider,
usePluginPageContext,
@@ -140,23 +141,17 @@ const PluginPage = ({
id="marketplace-container"
ref={containerRef}
style={{ scrollbarGutter: 'stable' }}
className={cn('relative flex grow flex-col overflow-y-auto border-t border-divider-subtle', isPluginsTab
? 'rounded-t-xl bg-components-panel-bg'
: 'bg-background-body')}
className="relative flex grow flex-col overflow-y-auto rounded-t-xl border-t border-divider-subtle bg-components-panel-bg"
>
<div
className={cn(
'sticky top-0 z-10 flex min-h-[60px] items-center gap-1 self-stretch bg-components-panel-bg px-12 pb-2 pt-4',
isExploringMarketplace && 'bg-background-body',
)}
>
<div className="sticky top-0 z-10 flex min-h-[60px] items-center gap-1 self-stretch bg-components-panel-bg px-12 pb-2 pt-4">
<div className="flex w-full items-center justify-between">
<div className="flex-1">
<div className="flex flex-1 items-center justify-start gap-2">
<TabSlider
value={isPluginsTab ? PLUGIN_PAGE_TABS_MAP.plugins : PLUGIN_PAGE_TABS_MAP.marketplace}
onChange={setActiveTab}
options={options}
/>
<SearchBoxWrapper wrapperClassName="w-[360px] mx-0" inputClassName="p-0" />
</div>
<div className="flex shrink-0 items-center gap-1">
{

View File

@@ -65,6 +65,7 @@
"autoUpdate.upgradeModePlaceholder.partial": "Only selected plugins will auto-update. No plugins are currently selected, so no plugins will auto-update.",
"category.agents": "Agent Strategies",
"category.all": "All",
"category.allTypes": "All types",
"category.bundles": "Bundles",
"category.datasources": "Data Sources",
"category.extensions": "Extensions",
@@ -194,7 +195,12 @@
"marketplace.difyMarketplace": "Dify Marketplace",
"marketplace.discover": "Discover",
"marketplace.empower": "Empower your AI development",
"marketplace.featured": "Featured",
"marketplace.heroSubtitle": "Use community-built plugins to power your AI development.",
"marketplace.heroTitle": "Discover. Extend. Build.",
"marketplace.installs": "installs",
"marketplace.moreFrom": "More from Marketplace",
"marketplace.ourTopPicks": "Our top picks to get you started",
"marketplace.noPluginFound": "No plugin found",
"marketplace.partnerTip": "Verified by a Dify partner",
"marketplace.pluginsResult": "{{num}} results",

View File

@@ -65,6 +65,7 @@
"autoUpdate.upgradeModePlaceholder.partial": "仅选定的插件将自动更新。目前未选择任何插件,因此不会自动更新任何插件。",
"category.agents": "Agent 策略",
"category.all": "全部",
"category.allTypes": "所有类型",
"category.bundles": "插件集",
"category.datasources": "数据源",
"category.extensions": "扩展",
@@ -194,7 +195,12 @@
"marketplace.difyMarketplace": "Dify 市场",
"marketplace.discover": "探索",
"marketplace.empower": "助力您的 AI 开发",
"marketplace.featured": "精选",
"marketplace.heroSubtitle": "使用社区构建的插件为您的 AI 开发提供动力。",
"marketplace.heroTitle": "探索。扩展。构建。",
"marketplace.installs": "次安装",
"marketplace.moreFrom": "更多来自市场",
"marketplace.ourTopPicks": "我们精选推荐",
"marketplace.noPluginFound": "未找到插件",
"marketplace.partnerTip": "此插件由 Dify 合作伙伴认证",
"marketplace.pluginsResult": "{{num}} 个插件结果",