mirror of
https://github.com/langgenius/dify.git
synced 2026-02-04 15:04:16 +00:00
Compare commits
2 Commits
fix/workfl
...
feat/add_t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
189ef19a1d | ||
|
|
53252d0395 |
@@ -6,7 +6,7 @@ const PluginList = () => {
|
||||
return (
|
||||
<PluginPage
|
||||
plugins={<PluginsPanel />}
|
||||
marketplace={<Marketplace pluginTypeSwitchClassName="top-[60px]" />}
|
||||
marketplace={<Marketplace />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import Link from 'next/link'
|
||||
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 +15,33 @@ 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">
|
||||
<span className="mr-1 text-text-tertiary">by</span>
|
||||
<Link
|
||||
href={`/creators/${orgName}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-text-secondary hover:underline"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{orgName}
|
||||
</Link>
|
||||
</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 +50,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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,72 +1,164 @@
|
||||
import { useLocale, useTranslation } from '#i18n'
|
||||
'use client'
|
||||
|
||||
const Description = () => {
|
||||
const { t } = useTranslation('plugin')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const locale = useLocale()
|
||||
import type { MotionValue } from 'motion/react'
|
||||
import { useTranslation } from '#i18n'
|
||||
import { motion, useMotionValue, useSpring, useTransform } from 'motion/react'
|
||||
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 PluginTypeSwitch from '../plugin-type-switch'
|
||||
|
||||
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
|
||||
scrollContainerId?: string
|
||||
}
|
||||
|
||||
export default Description
|
||||
// Constants for collapse animation
|
||||
const MAX_SCROLL = 120 // pixels to fully collapse
|
||||
const EXPANDED_PADDING_TOP = 32 // pt-8
|
||||
const COLLAPSED_PADDING_TOP = 12 // pt-3
|
||||
const EXPANDED_PADDING_BOTTOM = 24 // pb-6
|
||||
const COLLAPSED_PADDING_BOTTOM = 12 // pb-3
|
||||
|
||||
export const Description = ({
|
||||
className,
|
||||
scrollContainerId = 'marketplace-container',
|
||||
}: DescriptionProps) => {
|
||||
const { t } = useTranslation('plugin')
|
||||
const rafRef = useRef<number | null>(null)
|
||||
const lastProgressRef = useRef(0)
|
||||
const titleRef = useRef<HTMLDivElement | null>(null)
|
||||
const progress = useMotionValue(0)
|
||||
const titleHeight = useMotionValue(0)
|
||||
const smoothProgress = useSpring(progress, { stiffness: 260, damping: 34 })
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const node = titleRef.current
|
||||
if (!node)
|
||||
return
|
||||
|
||||
const updateHeight = () => {
|
||||
titleHeight.set(node.scrollHeight)
|
||||
}
|
||||
|
||||
updateHeight()
|
||||
|
||||
if (typeof ResizeObserver === 'undefined')
|
||||
return
|
||||
|
||||
const observer = new ResizeObserver(updateHeight)
|
||||
observer.observe(node)
|
||||
return () => observer.disconnect()
|
||||
}, [titleHeight])
|
||||
|
||||
useEffect(() => {
|
||||
const container = document.getElementById(scrollContainerId)
|
||||
if (!container)
|
||||
return
|
||||
|
||||
const handleScroll = () => {
|
||||
// Cancel any pending animation frame
|
||||
if (rafRef.current)
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
|
||||
// Use requestAnimationFrame for smooth updates
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
const scrollTop = Math.round(container.scrollTop)
|
||||
const rawProgress = Math.min(Math.max(scrollTop / MAX_SCROLL, 0), 1)
|
||||
const snappedProgress = rawProgress >= 0.95
|
||||
? 1
|
||||
: rawProgress <= 0.05
|
||||
? 0
|
||||
: Math.round(rawProgress * 100) / 100
|
||||
|
||||
if (snappedProgress !== lastProgressRef.current) {
|
||||
lastProgressRef.current = snappedProgress
|
||||
progress.set(snappedProgress)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
container.addEventListener('scroll', handleScroll, { passive: true })
|
||||
|
||||
// Initial check
|
||||
handleScroll()
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('scroll', handleScroll)
|
||||
if (rafRef.current)
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
}
|
||||
}, [progress, scrollContainerId])
|
||||
|
||||
// Calculate interpolated values
|
||||
const contentOpacity = useTransform(smoothProgress, [0, 1], [1, 0])
|
||||
const contentScale = useTransform(smoothProgress, [0, 1], [1, 0.9])
|
||||
const titleMaxHeight: MotionValue<number> = useTransform(
|
||||
[smoothProgress, titleHeight],
|
||||
(values: number[]) => values[1] * (1 - values[0]),
|
||||
)
|
||||
const tabsMarginTop = useTransform(smoothProgress, [0, 1], [48, 0])
|
||||
const paddingTop = useTransform(smoothProgress, [0, 1], [EXPANDED_PADDING_TOP, COLLAPSED_PADDING_TOP])
|
||||
const paddingBottom = useTransform(smoothProgress, [0, 1], [EXPANDED_PADDING_BOTTOM, COLLAPSED_PADDING_BOTTOM])
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={cn(
|
||||
'sticky top-[60px] z-20 mx-4 mt-4 shrink-0 overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border px-6',
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
paddingTop,
|
||||
paddingBottom,
|
||||
}}
|
||||
>
|
||||
{/* Blue base background */}
|
||||
<div className="absolute inset-0 bg-[rgba(0,51,255,0.9)]" />
|
||||
|
||||
{/* Decorative image with blend mode - showing top 1/3 of the image */}
|
||||
<div
|
||||
className="absolute inset-0 bg-no-repeat opacity-80 mix-blend-lighten"
|
||||
style={{
|
||||
backgroundImage: `url(${marketPlaceBg.src})`,
|
||||
backgroundSize: '110% auto',
|
||||
backgroundPosition: 'center top',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Gradient & Noise overlay */}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||
style={{ backgroundImage: `url(${marketplaceGradientNoise.src})` }}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10">
|
||||
{/* Title and subtitle - fade out and scale down */}
|
||||
<motion.div
|
||||
ref={titleRef}
|
||||
style={{
|
||||
opacity: contentOpacity,
|
||||
scale: contentScale,
|
||||
transformOrigin: 'left top',
|
||||
maxHeight: titleMaxHeight,
|
||||
overflow: 'hidden',
|
||||
willChange: 'opacity, transform',
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</motion.div>
|
||||
|
||||
{/* Plugin type switch tabs - always visible */}
|
||||
<motion.div style={{ marginTop: tabsMarginTop }}>
|
||||
<PluginTypeSwitch variant="hero" />
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import type { SearchParams } from 'nuqs'
|
||||
import { TanstackQueryInitializer } from '@/context/query-client'
|
||||
import Description from './description'
|
||||
import { HydrateQueryClient } from './hydration-server'
|
||||
import ListWrapper from './list/list-wrapper'
|
||||
import StickySearchAndSwitchWrapper from './sticky-search-and-switch-wrapper'
|
||||
import MarketplaceHeader from './marketplace-header'
|
||||
|
||||
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}
|
||||
/>
|
||||
<MarketplaceHeader descriptionClassName="mx-12 mt-1" />
|
||||
<ListWrapper
|
||||
showInstallButton={showInstallButton}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
255
web/app/components/plugins/marketplace/list/carousel.tsx
Normal file
255
web/app/components/plugins/marketplace/list/carousel.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
'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 SCROLL_OVERLAP_RATIO = 0.5
|
||||
|
||||
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 scrollToPage = useCallback((pageIndex: number, instant = false) => {
|
||||
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: instant ? 'instant' : 'smooth',
|
||||
})
|
||||
}, [itemWidth, gap])
|
||||
|
||||
const scroll = useCallback((direction: 'left' | 'right') => {
|
||||
const container = containerRef.current
|
||||
if (!container)
|
||||
return
|
||||
|
||||
// Handle looping
|
||||
if (direction === 'left' && !scrollState.canScrollLeft) {
|
||||
// At first page, loop to last page
|
||||
scrollToPage(scrollState.totalPages - 1, true)
|
||||
return
|
||||
}
|
||||
if (direction === 'right' && !scrollState.canScrollRight) {
|
||||
// At last page, loop to first page
|
||||
scrollToPage(0, true)
|
||||
return
|
||||
}
|
||||
|
||||
const scrollAmount = container.clientWidth - (itemWidth * SCROLL_OVERLAP_RATIO)
|
||||
const newScrollLeft = direction === 'left'
|
||||
? container.scrollLeft - scrollAmount
|
||||
: container.scrollLeft + scrollAmount
|
||||
|
||||
container.scrollTo({
|
||||
left: newScrollLeft,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}, [itemWidth, scrollState.canScrollLeft, scrollState.canScrollRight, scrollState.totalPages, scrollToPage])
|
||||
|
||||
// Auto-play functionality
|
||||
useEffect(() => {
|
||||
if (!autoPlay || isHovered || scrollState.totalPages <= 1)
|
||||
return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (scrollState.canScrollRight) {
|
||||
scrollToPage(scrollState.currentPage + 1)
|
||||
}
|
||||
else {
|
||||
// Loop back to first page instantly (no animation)
|
||||
scrollToPage(0, true)
|
||||
}
|
||||
}, 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.totalPages <= 1}
|
||||
onClick={() => scroll('left')}
|
||||
Icon={RiArrowLeftSLine}
|
||||
/>
|
||||
<NavButton
|
||||
direction="right"
|
||||
disabled={scrollState.totalPages <= 1}
|
||||
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
|
||||
@@ -40,7 +40,7 @@ const List = ({
|
||||
{
|
||||
plugins && !!plugins.length && (
|
||||
<div className={cn(
|
||||
'grid grid-cols-4 gap-3',
|
||||
'grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
|
||||
cardContainerClassName,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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 // show up to 8 items
|
||||
|
||||
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 w-[calc((100%-0px)/1)] shrink-0 flex-col gap-3 sm:w-[calc((100%-12px)/2)] lg:w-[calc((100%-24px)/3)] xl:w-[calc((100%-36px)/4)]"
|
||||
style={{ scrollSnapAlign: 'start' }}
|
||||
>
|
||||
{columnPlugins.map(plugin => (
|
||||
<div key={plugin.plugin_id}>
|
||||
{renderPluginCard(plugin)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</Carousel>
|
||||
)
|
||||
}
|
||||
|
||||
const renderGridCollection = (collection: MarketplaceCollection, plugins: Plugin[]) => {
|
||||
// Other collections: responsive grid
|
||||
const displayPlugins = plugins.slice(0, GRID_DISPLAY_LIMIT)
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{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>
|
||||
))
|
||||
)
|
||||
})
|
||||
}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
'use client'
|
||||
import type { ActivePluginType } from '../constants'
|
||||
import { useTranslation } from '#i18n'
|
||||
import { useState } from 'react'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import SegmentedControl from '@/app/components/base/segmented-control'
|
||||
import CategoriesFilter from '../../plugin-page/filter-management/category-filter'
|
||||
import TagFilter from '../../plugin-page/filter-management/tag-filter'
|
||||
import { useActivePluginType, useFilterPluginTags, useMarketplaceSearchMode } from '../atoms'
|
||||
import { PLUGIN_TYPE_SEARCH_MAP } from '../constants'
|
||||
import SortDropdown from '../sort-dropdown'
|
||||
import { useMarketplaceData } from '../state'
|
||||
import List from './index'
|
||||
@@ -8,10 +15,21 @@ import List from './index'
|
||||
type ListWrapperProps = {
|
||||
showInstallButton?: boolean
|
||||
}
|
||||
type SearchScope = 'all' | 'plugins' | 'creators'
|
||||
const searchScopeOptionKeys = [
|
||||
{ value: 'all', textKey: 'marketplace.searchFilterAll' },
|
||||
{ value: 'plugins', textKey: 'marketplace.searchFilterPlugins' },
|
||||
{ value: 'creators', textKey: 'marketplace.searchFilterCreators' },
|
||||
] as const satisfies ReadonlyArray<{ value: SearchScope, textKey: 'marketplace.searchFilterAll' | 'marketplace.searchFilterPlugins' | 'marketplace.searchFilterCreators' }>
|
||||
|
||||
const ListWrapper = ({
|
||||
showInstallButton,
|
||||
}: ListWrapperProps) => {
|
||||
const { t } = useTranslation()
|
||||
const isSearchMode = useMarketplaceSearchMode()
|
||||
const [filterPluginTags, handleFilterPluginTagsChange] = useFilterPluginTags()
|
||||
const [activePluginType, handleActivePluginTypeChange] = useActivePluginType()
|
||||
const [searchScope, setSearchScope] = useState<SearchScope>('all')
|
||||
|
||||
const {
|
||||
plugins,
|
||||
@@ -22,21 +40,55 @@ const ListWrapper = ({
|
||||
isFetchingNextPage,
|
||||
page,
|
||||
} = useMarketplaceData()
|
||||
const pluginsCount = pluginsTotal || 0
|
||||
const searchScopeOptions: Array<{ value: SearchScope, text: string, count: number }> = searchScopeOptionKeys.map(option => ({
|
||||
value: option.value,
|
||||
text: t(option.textKey, { ns: 'plugin' }),
|
||||
count: option.value === 'creators' ? 0 : pluginsCount,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ scrollbarGutter: 'stable' }}
|
||||
className="relative flex grow flex-col bg-background-default-subtle px-12 py-2"
|
||||
>
|
||||
{
|
||||
plugins && (
|
||||
<div className="mb-4 flex items-center pt-3">
|
||||
<div className="title-xl-semi-bold text-text-primary">{t('marketplace.pluginsResult', { ns: 'plugin', num: pluginsTotal })}</div>
|
||||
<div className="mx-3 h-3.5 w-[1px] bg-divider-regular"></div>
|
||||
<SortDropdown />
|
||||
{plugins && !isSearchMode && (
|
||||
<div className="mb-4 flex items-center pt-3">
|
||||
<div className="title-xl-semi-bold text-text-primary">{t('marketplace.pluginsResult', { ns: 'plugin', num: pluginsTotal })}</div>
|
||||
<div className="mx-3 h-3.5 w-[1px] bg-divider-regular"></div>
|
||||
<SortDropdown />
|
||||
</div>
|
||||
)}
|
||||
{isSearchMode && (
|
||||
<div className="mb-4 flex items-center justify-between pt-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<SegmentedControl
|
||||
size="large"
|
||||
activeState="accentLight"
|
||||
value={searchScope}
|
||||
onChange={(value) => {
|
||||
setSearchScope(value as SearchScope)
|
||||
}}
|
||||
options={searchScopeOptions}
|
||||
/>
|
||||
<CategoriesFilter
|
||||
value={activePluginType === PLUGIN_TYPE_SEARCH_MAP.all ? [] : [activePluginType]}
|
||||
onChange={(categories) => {
|
||||
if (categories.length === 0) {
|
||||
handleActivePluginTypeChange(PLUGIN_TYPE_SEARCH_MAP.all)
|
||||
return
|
||||
}
|
||||
handleActivePluginTypeChange(categories[categories.length - 1] as ActivePluginType)
|
||||
}}
|
||||
/>
|
||||
<TagFilter
|
||||
value={filterPluginTags}
|
||||
onChange={handleFilterPluginTagsChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<SortDropdown />
|
||||
</div>
|
||||
)}
|
||||
{
|
||||
isLoading && page === 1 && (
|
||||
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import { useMarketplaceSearchMode } from './atoms'
|
||||
import { Description } from './description'
|
||||
import SearchResultsHeader from './search-results-header'
|
||||
|
||||
type MarketplaceHeaderProps = {
|
||||
descriptionClassName?: string
|
||||
}
|
||||
|
||||
const MarketplaceHeader = ({ descriptionClassName }: MarketplaceHeaderProps) => {
|
||||
const isSearchMode = useMarketplaceSearchMode()
|
||||
|
||||
if (isSearchMode)
|
||||
return <SearchResultsHeader />
|
||||
|
||||
return <Description className={descriptionClassName} />
|
||||
}
|
||||
|
||||
export default MarketplaceHeader
|
||||
21
web/app/components/plugins/marketplace/plugin-type-icons.tsx
Normal file
21
web/app/components/plugins/marketplace/plugin-type-icons.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { ComponentType } from 'react'
|
||||
import {
|
||||
RiBrain2Line,
|
||||
RiDatabase2Line,
|
||||
RiHammerLine,
|
||||
RiPuzzle2Line,
|
||||
RiSpeakAiLine,
|
||||
} from '@remixicon/react'
|
||||
import { Trigger as TriggerIcon } from '@/app/components/base/icons/src/vender/plugin'
|
||||
import { PluginCategoryEnum } from '../types'
|
||||
|
||||
export type PluginTypeIconComponent = ComponentType<{ className?: string }>
|
||||
|
||||
export const MARKETPLACE_TYPE_ICON_COMPONENTS: Record<PluginCategoryEnum, PluginTypeIconComponent> = {
|
||||
[PluginCategoryEnum.tool]: RiHammerLine,
|
||||
[PluginCategoryEnum.model]: RiBrain2Line,
|
||||
[PluginCategoryEnum.datasource]: RiDatabase2Line,
|
||||
[PluginCategoryEnum.trigger]: TriggerIcon,
|
||||
[PluginCategoryEnum.agent]: RiSpeakAiLine,
|
||||
[PluginCategoryEnum.extension]: RiPuzzle2Line,
|
||||
}
|
||||
@@ -1,30 +1,40 @@
|
||||
'use client'
|
||||
import type { ActivePluginType } from './constants'
|
||||
import type { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import { useTranslation } from '#i18n'
|
||||
import {
|
||||
RiApps2Line,
|
||||
RiArchive2Line,
|
||||
RiBrain2Line,
|
||||
RiDatabase2Line,
|
||||
RiHammerLine,
|
||||
RiPuzzle2Line,
|
||||
RiSpeakAiLine,
|
||||
} from '@remixicon/react'
|
||||
import { useSetAtom } from 'jotai'
|
||||
import { Trigger as TriggerIcon } from '@/app/components/base/icons/src/vender/plugin'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { searchModeAtom, useActivePluginType } from './atoms'
|
||||
import { PLUGIN_CATEGORY_WITH_COLLECTIONS, PLUGIN_TYPE_SEARCH_MAP } from './constants'
|
||||
import { MARKETPLACE_TYPE_ICON_COMPONENTS } from './plugin-type-icons'
|
||||
|
||||
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 getTypeIcon = (value: ActivePluginType) => {
|
||||
if (value === PLUGIN_TYPE_SEARCH_MAP.all)
|
||||
return isHeroVariant ? <RiApps2Line className="mr-1.5 h-4 w-4" /> : null
|
||||
if (value === PLUGIN_TYPE_SEARCH_MAP.bundle)
|
||||
return <RiArchive2Line className="mr-1.5 h-4 w-4" />
|
||||
const Icon = MARKETPLACE_TYPE_ICON_COMPONENTS[value as PluginCategoryEnum]
|
||||
return Icon ? <Icon className="mr-1.5 h-4 w-4" /> : null
|
||||
}
|
||||
|
||||
const options: Array<{
|
||||
value: ActivePluginType
|
||||
text: string
|
||||
@@ -32,49 +42,65 @@ 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: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.all),
|
||||
},
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.model,
|
||||
text: t('category.models', { ns: 'plugin' }),
|
||||
icon: <RiBrain2Line className="mr-1.5 h-4 w-4" />,
|
||||
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.model),
|
||||
},
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.tool,
|
||||
text: t('category.tools', { ns: 'plugin' }),
|
||||
icon: <RiHammerLine className="mr-1.5 h-4 w-4" />,
|
||||
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.tool),
|
||||
},
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.datasource,
|
||||
text: t('category.datasources', { ns: 'plugin' }),
|
||||
icon: <RiDatabase2Line className="mr-1.5 h-4 w-4" />,
|
||||
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.datasource),
|
||||
},
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.trigger,
|
||||
text: t('category.triggers', { ns: 'plugin' }),
|
||||
icon: <TriggerIcon className="mr-1.5 h-4 w-4" />,
|
||||
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.trigger),
|
||||
},
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.agent,
|
||||
text: t('category.agents', { ns: 'plugin' }),
|
||||
icon: <RiSpeakAiLine className="mr-1.5 h-4 w-4" />,
|
||||
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.agent),
|
||||
},
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.extension,
|
||||
text: t('category.extensions', { ns: 'plugin' }),
|
||||
icon: <RiPuzzle2Line className="mr-1.5 h-4 w-4" />,
|
||||
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.extension),
|
||||
},
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.bundle,
|
||||
text: t('category.bundles', { ns: 'plugin' }),
|
||||
icon: <RiArchive2Line className="mr-1.5 h-4 w-4" />,
|
||||
icon: getTypeIcon(PLUGIN_TYPE_SEARCH_MAP.bundle),
|
||||
},
|
||||
]
|
||||
|
||||
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 +108,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)) {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { Tag } from '@/app/components/plugins/hooks'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PluginCategoryEnum } from '../../types'
|
||||
import SearchBox from './index'
|
||||
import SearchBoxWrapper from './search-box-wrapper'
|
||||
import SearchDropdown from './search-dropdown'
|
||||
import MarketplaceTrigger from './trigger/marketplace'
|
||||
import ToolSelectorTrigger from './trigger/tool-selector'
|
||||
|
||||
@@ -13,32 +16,72 @@ import ToolSelectorTrigger from './trigger/tool-selector'
|
||||
// Mock i18n translation hook
|
||||
vi.mock('#i18n', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string }) => {
|
||||
t: (key: string, options?: { ns?: string, num?: number, author?: string }) => {
|
||||
// Build full key with namespace prefix if provided
|
||||
const fullKey = options?.ns ? `${options.ns}.${key}` : key
|
||||
const translations: Record<string, string> = {
|
||||
'pluginTags.allTags': 'All Tags',
|
||||
'pluginTags.searchTags': 'Search tags',
|
||||
'plugin.searchPlugins': 'Search plugins',
|
||||
'plugin.install': `${options?.num || 0} installs`,
|
||||
'plugin.marketplace.searchDropdown.plugins': 'Plugins',
|
||||
'plugin.marketplace.searchDropdown.showAllResults': 'Show all search results',
|
||||
'plugin.marketplace.searchDropdown.enter': 'Enter',
|
||||
'plugin.marketplace.searchDropdown.byAuthor': `by ${options?.author || ''}`,
|
||||
}
|
||||
return translations[fullKey] || key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useDebounce: (value: string) => value,
|
||||
}))
|
||||
|
||||
vi.mock('jotai', async () => {
|
||||
const actual = await vi.importActual<typeof import('jotai')>('jotai')
|
||||
return {
|
||||
...actual,
|
||||
useSetAtom: () => vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/hooks/use-i18n', () => ({
|
||||
useRenderI18nObject: () => (value: Record<string, string> | string) => {
|
||||
if (typeof value === 'string')
|
||||
return value
|
||||
return value.en_US || Object.values(value)[0] || ''
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock marketplace state hooks
|
||||
const { mockSearchPluginText, mockHandleSearchPluginTextChange, mockFilterPluginTags, mockHandleFilterPluginTagsChange } = vi.hoisted(() => {
|
||||
const {
|
||||
mockSearchPluginText,
|
||||
mockHandleSearchPluginTextChange,
|
||||
mockFilterPluginTags,
|
||||
mockHandleFilterPluginTagsChange,
|
||||
mockActivePluginType,
|
||||
mockSortValue,
|
||||
} = vi.hoisted(() => {
|
||||
return {
|
||||
mockSearchPluginText: '',
|
||||
mockHandleSearchPluginTextChange: vi.fn(),
|
||||
mockFilterPluginTags: [] as string[],
|
||||
mockHandleFilterPluginTagsChange: vi.fn(),
|
||||
mockActivePluginType: 'all',
|
||||
mockSortValue: {
|
||||
sortBy: 'install_count',
|
||||
sortOrder: 'DESC',
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../atoms', () => ({
|
||||
useSearchPluginText: () => [mockSearchPluginText, mockHandleSearchPluginTextChange],
|
||||
useFilterPluginTags: () => [mockFilterPluginTags, mockHandleFilterPluginTagsChange],
|
||||
useActivePluginType: () => [mockActivePluginType, vi.fn()],
|
||||
useMarketplaceSortValue: () => mockSortValue,
|
||||
searchModeAtom: {},
|
||||
}))
|
||||
|
||||
// Mock useTags hook
|
||||
@@ -60,8 +103,57 @@ vi.mock('@/app/components/plugins/hooks', () => ({
|
||||
tags: mockTags,
|
||||
tagsMap: mockTagsMap,
|
||||
}),
|
||||
useCategories: () => ({
|
||||
categoriesMap: {
|
||||
'tool': { name: 'tool', label: 'Tool' },
|
||||
'model': { name: 'model', label: 'Model' },
|
||||
'datasource': { name: 'datasource', label: 'Data Source' },
|
||||
'trigger': { name: 'trigger', label: 'Trigger' },
|
||||
'agent-strategy': { name: 'agent-strategy', label: 'Agent Strategy' },
|
||||
'extension': { name: 'extension', label: 'Extension' },
|
||||
'bundle': { name: 'bundle', label: 'Bundle' },
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
let mockDropdownPlugins: Plugin[] = []
|
||||
vi.mock('../query', () => ({
|
||||
useMarketplacePlugins: () => ({
|
||||
data: { pages: [{ plugins: mockDropdownPlugins }] },
|
||||
isLoading: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
const createPlugin = (overrides: Partial<Plugin> = {}): Plugin => ({
|
||||
type: 'plugin',
|
||||
org: 'dropbox',
|
||||
author: 'dropbox',
|
||||
name: 'dropbox-search',
|
||||
plugin_id: 'plugin-1',
|
||||
version: '1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
latest_package_identifier: 'pkg-1',
|
||||
icon: 'https://example.com/icon.png',
|
||||
verified: false,
|
||||
label: { en_US: 'Dropbox search' },
|
||||
brief: { en_US: 'Interact with Dropbox files.' },
|
||||
description: { en_US: 'Interact with Dropbox files.' },
|
||||
introduction: '',
|
||||
repository: '',
|
||||
category: PluginCategoryEnum.tool,
|
||||
install_count: 206,
|
||||
endpoint: {
|
||||
settings: [],
|
||||
},
|
||||
tags: [],
|
||||
badges: [],
|
||||
verification: {
|
||||
authorized_category: 'community',
|
||||
},
|
||||
from: 'marketplace',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// Mock portal-to-follow-elem with shared open state
|
||||
let mockPortalOpenState = false
|
||||
|
||||
@@ -115,6 +207,7 @@ describe('SearchBox', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPortalOpenState = false
|
||||
mockDropdownPlugins = []
|
||||
})
|
||||
|
||||
// ================================
|
||||
@@ -424,6 +517,64 @@ describe('SearchBox', () => {
|
||||
expect(onSearchChange).toHaveBeenCalledWith(' ')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Submission Tests
|
||||
// ================================
|
||||
describe('Submission', () => {
|
||||
it('should call onSearchSubmit when pressing Enter', () => {
|
||||
const onSearchSubmit = vi.fn()
|
||||
render(<SearchBox {...defaultProps} onSearchSubmit={onSearchSubmit} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
expect(onSearchSubmit).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// SearchDropdown Component Tests
|
||||
// ================================
|
||||
describe('SearchDropdown', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render plugin items and metadata', () => {
|
||||
render(
|
||||
<SearchDropdown
|
||||
query="dropbox"
|
||||
plugins={[createPlugin()]}
|
||||
onShowAll={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Plugins')).toBeInTheDocument()
|
||||
expect(screen.getByText('Dropbox search')).toBeInTheDocument()
|
||||
expect(screen.getByText('Tool')).toBeInTheDocument()
|
||||
expect(screen.getByText('206 installs')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Interactions', () => {
|
||||
it('should call onShowAll when clicking show all results', () => {
|
||||
const onShowAll = vi.fn()
|
||||
render(
|
||||
<SearchDropdown
|
||||
query="dropbox"
|
||||
plugins={[createPlugin()]}
|
||||
onShowAll={onShowAll}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Show all search results'))
|
||||
|
||||
expect(onShowAll).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
@@ -433,6 +584,7 @@ describe('SearchBoxWrapper', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPortalOpenState = false
|
||||
mockDropdownPlugins = []
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
@@ -457,12 +609,22 @@ describe('SearchBoxWrapper', () => {
|
||||
})
|
||||
|
||||
describe('Hook Integration', () => {
|
||||
it('should call handleSearchPluginTextChange when search changes', () => {
|
||||
it('should not commit search when input changes', () => {
|
||||
render(<SearchBoxWrapper />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'new search' } })
|
||||
|
||||
expect(mockHandleSearchPluginTextChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should commit search when pressing Enter', () => {
|
||||
render(<SearchBoxWrapper />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'new search' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
expect(mockHandleSearchPluginTextChange).toHaveBeenCalledWith('new search')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,9 @@ import TagsFilter from './tags-filter'
|
||||
type SearchBoxProps = {
|
||||
search: string
|
||||
onSearchChange: (search: string) => void
|
||||
onSearchSubmit?: () => void
|
||||
onSearchFocus?: () => void
|
||||
onSearchBlur?: () => void
|
||||
wrapperClassName?: string
|
||||
inputClassName?: string
|
||||
tags: string[]
|
||||
@@ -22,6 +25,9 @@ type SearchBoxProps = {
|
||||
const SearchBox = ({
|
||||
search,
|
||||
onSearchChange,
|
||||
onSearchSubmit,
|
||||
onSearchFocus,
|
||||
onSearchBlur,
|
||||
wrapperClassName,
|
||||
inputClassName,
|
||||
tags,
|
||||
@@ -58,6 +64,12 @@ const SearchBox = ({
|
||||
onChange={(e) => {
|
||||
onSearchChange(e.target.value)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter')
|
||||
onSearchSubmit?.()
|
||||
}}
|
||||
onFocus={onSearchFocus}
|
||||
onBlur={onSearchBlur}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
{
|
||||
@@ -89,6 +101,12 @@ const SearchBox = ({
|
||||
onChange={(e) => {
|
||||
onSearchChange(e.target.value)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter')
|
||||
onSearchSubmit?.()
|
||||
}}
|
||||
onFocus={onSearchFocus}
|
||||
onBlur={onSearchBlur}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
{
|
||||
|
||||
@@ -1,25 +1,126 @@
|
||||
'use client'
|
||||
|
||||
import type { PluginsSearchParams } from '../types'
|
||||
import { useTranslation } from '#i18n'
|
||||
import { useFilterPluginTags, useSearchPluginText } from '../atoms'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { useSetAtom } from 'jotai'
|
||||
import { useMemo, useState } from 'react'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import {
|
||||
searchModeAtom,
|
||||
useActivePluginType,
|
||||
useFilterPluginTags,
|
||||
useMarketplaceSortValue,
|
||||
useSearchPluginText,
|
||||
} from '../atoms'
|
||||
import { PLUGIN_TYPE_SEARCH_MAP } from '../constants'
|
||||
import { useMarketplacePlugins } from '../query'
|
||||
import { getMarketplaceListFilterType } from '../utils'
|
||||
import SearchBox from './index'
|
||||
import SearchDropdown from './search-dropdown'
|
||||
|
||||
const SearchBoxWrapper = () => {
|
||||
type SearchBoxWrapperProps = {
|
||||
wrapperClassName?: string
|
||||
inputClassName?: string
|
||||
}
|
||||
const SearchBoxWrapper = ({
|
||||
wrapperClassName,
|
||||
inputClassName,
|
||||
}: SearchBoxWrapperProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [searchPluginText, handleSearchPluginTextChange] = useSearchPluginText()
|
||||
const [filterPluginTags, handleFilterPluginTagsChange] = useFilterPluginTags()
|
||||
const [activePluginType] = useActivePluginType()
|
||||
const sort = useMarketplaceSortValue()
|
||||
const setSearchMode = useSetAtom(searchModeAtom)
|
||||
const committedSearch = searchPluginText || ''
|
||||
const [draftSearch, setDraftSearch] = useState(committedSearch)
|
||||
const [isFocused, setIsFocused] = useState(false)
|
||||
const [isHoveringDropdown, setIsHoveringDropdown] = useState(false)
|
||||
const debouncedDraft = useDebounce(draftSearch, { wait: 300 })
|
||||
const hasDraft = !!debouncedDraft.trim()
|
||||
|
||||
const dropdownQueryParams = useMemo(() => {
|
||||
if (!hasDraft)
|
||||
return undefined
|
||||
const filterType = getMarketplaceListFilterType(activePluginType) as PluginsSearchParams['type']
|
||||
return {
|
||||
query: debouncedDraft.trim(),
|
||||
category: activePluginType === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginType,
|
||||
tags: filterPluginTags,
|
||||
sort_by: sort.sortBy,
|
||||
sort_order: sort.sortOrder,
|
||||
type: filterType,
|
||||
page_size: 3,
|
||||
}
|
||||
}, [activePluginType, debouncedDraft, filterPluginTags, hasDraft, sort.sortBy, sort.sortOrder])
|
||||
|
||||
const dropdownQuery = useMarketplacePlugins(dropdownQueryParams)
|
||||
const dropdownPlugins = dropdownQuery.data?.pages[0]?.plugins || []
|
||||
|
||||
const handleSubmit = () => {
|
||||
const trimmed = draftSearch.trim()
|
||||
if (!trimmed)
|
||||
return
|
||||
handleSearchPluginTextChange(trimmed)
|
||||
setSearchMode(true)
|
||||
setIsFocused(false)
|
||||
}
|
||||
|
||||
const inputValue = isFocused ? draftSearch : committedSearch
|
||||
const isDropdownOpen = hasDraft && (isFocused || isHoveringDropdown)
|
||||
|
||||
return (
|
||||
<SearchBox
|
||||
wrapperClassName="z-[11] mx-auto w-[640px] shrink-0"
|
||||
inputClassName="w-full"
|
||||
search={searchPluginText}
|
||||
onSearchChange={handleSearchPluginTextChange}
|
||||
tags={filterPluginTags}
|
||||
onTagsChange={handleFilterPluginTagsChange}
|
||||
placeholder={t('searchPlugins', { ns: 'plugin' })}
|
||||
usedInMarketplace
|
||||
/>
|
||||
<PortalToFollowElem
|
||||
placement="bottom-start"
|
||||
offset={8}
|
||||
open={isDropdownOpen}
|
||||
onOpenChange={setIsFocused}
|
||||
>
|
||||
<PortalToFollowElemTrigger asChild>
|
||||
<div>
|
||||
<SearchBox
|
||||
wrapperClassName={cn('z-[11] mx-auto w-[640px] shrink-0', wrapperClassName)}
|
||||
inputClassName={cn('w-full', inputClassName)}
|
||||
search={inputValue}
|
||||
onSearchChange={setDraftSearch}
|
||||
onSearchSubmit={handleSubmit}
|
||||
onSearchFocus={() => {
|
||||
setDraftSearch(committedSearch)
|
||||
setIsFocused(true)
|
||||
}}
|
||||
onSearchBlur={() => {
|
||||
if (!isHoveringDropdown)
|
||||
setIsFocused(false)
|
||||
}}
|
||||
tags={filterPluginTags}
|
||||
onTagsChange={handleFilterPluginTagsChange}
|
||||
placeholder={t('searchPlugins', { ns: 'plugin' })}
|
||||
usedInMarketplace
|
||||
/>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent
|
||||
className="z-[1001]"
|
||||
onMouseEnter={() => setIsHoveringDropdown(true)}
|
||||
onMouseLeave={() => setIsHoveringDropdown(false)}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
}}
|
||||
>
|
||||
<SearchDropdown
|
||||
query={debouncedDraft.trim()}
|
||||
plugins={dropdownPlugins}
|
||||
onShowAll={handleSubmit}
|
||||
isLoading={dropdownQuery.isLoading}
|
||||
/>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { useTranslation } from '#i18n'
|
||||
import { RiArrowRightLine } from '@remixicon/react'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { useCategories } from '@/app/components/plugins/hooks'
|
||||
import { useRenderI18nObject } from '@/hooks/use-i18n'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { MARKETPLACE_TYPE_ICON_COMPONENTS } from '../../plugin-type-icons'
|
||||
import { getPluginDetailLinkInMarketplace } from '../../utils'
|
||||
|
||||
type SearchDropdownProps = {
|
||||
query: string
|
||||
plugins: Plugin[]
|
||||
onShowAll: () => void
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
const SearchDropdown = ({
|
||||
query,
|
||||
plugins,
|
||||
onShowAll,
|
||||
isLoading = false,
|
||||
}: SearchDropdownProps) => {
|
||||
const { t } = useTranslation()
|
||||
const getValueFromI18nObject = useRenderI18nObject()
|
||||
const { categoriesMap } = useCategories(true)
|
||||
|
||||
return (
|
||||
<div className="w-[472px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-xl backdrop-blur-sm">
|
||||
<div className="flex flex-col">
|
||||
{isLoading && !plugins.length && (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loading />
|
||||
</div>
|
||||
)}
|
||||
{!!plugins.length && (
|
||||
<div className="p-1">
|
||||
<div className="system-xs-semibold-uppercase px-3 pb-2 pt-3 text-text-primary">
|
||||
{t('marketplace.searchDropdown.plugins', { ns: 'plugin' })}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
{plugins.map((plugin) => {
|
||||
const title = getValueFromI18nObject(plugin.label) || plugin.name
|
||||
const description = getValueFromI18nObject(plugin.brief) || ''
|
||||
const categoryLabel = categoriesMap[plugin.category]?.label || plugin.category
|
||||
const installLabel = t('install', { ns: 'plugin', num: plugin.install_count || 0 })
|
||||
const author = plugin.org || plugin.author || ''
|
||||
const TypeIcon = MARKETPLACE_TYPE_ICON_COMPONENTS[plugin.category]
|
||||
return (
|
||||
<a
|
||||
key={`${plugin.org}/${plugin.name}`}
|
||||
className={cn(
|
||||
'flex gap-2 rounded-lg px-3 py-2 hover:bg-state-base-hover',
|
||||
)}
|
||||
href={getPluginDetailLinkInMarketplace(plugin)}
|
||||
>
|
||||
<div className="flex h-7 w-7 items-center justify-center overflow-hidden rounded-lg border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge">
|
||||
<img className="h-full w-full object-cover" src={plugin.icon} alt={title} />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="system-sm-medium truncate text-text-primary">{title}</div>
|
||||
{!!description && (
|
||||
<div className="system-xs-regular truncate text-text-tertiary">{description}</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 pt-0.5 text-text-tertiary">
|
||||
<div className="flex items-center gap-1">
|
||||
{TypeIcon && <TypeIcon className="h-4 w-4 text-text-tertiary" />}
|
||||
<span className="system-xs-regular">{categoryLabel}</span>
|
||||
</div>
|
||||
<span className="system-xs-regular">·</span>
|
||||
<span className="system-xs-regular">
|
||||
{t('marketplace.searchDropdown.byAuthor', { ns: 'plugin', author })}
|
||||
</span>
|
||||
<span className="system-xs-regular">·</span>
|
||||
<span className="system-xs-regular">{installLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t border-divider-subtle p-1">
|
||||
<button
|
||||
className="group flex w-full items-center justify-between rounded-lg px-3 py-2 text-left"
|
||||
onClick={onShowAll}
|
||||
type="button"
|
||||
>
|
||||
<span className="system-sm-medium text-text-accent">
|
||||
{t('marketplace.searchDropdown.showAllResults', { ns: 'plugin', query })}
|
||||
</span>
|
||||
<span className="flex items-center">
|
||||
<span className="system-2xs-medium-uppercase rounded-[5px] border border-divider-deep px-1.5 py-0.5 text-text-tertiary group-hover:hidden">
|
||||
{t('marketplace.searchDropdown.enter', { ns: 'plugin' })}
|
||||
</span>
|
||||
<RiArrowRightLine className="hidden h-5 w-5 text-text-tertiary group-hover:block" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SearchDropdown
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as MarketplaceTrigger } from './marketplace'
|
||||
export { default as ToolSelectorTrigger } from './tool-selector'
|
||||
@@ -0,0 +1,30 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslation } from '#i18n'
|
||||
import { useSearchPluginText } from './atoms'
|
||||
|
||||
const SearchResultsHeader = () => {
|
||||
const { t } = useTranslation('plugin')
|
||||
const [searchPluginText] = useSearchPluginText()
|
||||
|
||||
return (
|
||||
<div className="px-12 py-4">
|
||||
<div className="flex items-center gap-1 system-xs-regular text-text-tertiary">
|
||||
<span>{t('marketplace.searchBreadcrumbMarketplace')}</span>
|
||||
<span className="text-text-quaternary">/</span>
|
||||
<span>{t('marketplace.searchBreadcrumbSearch')}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex items-end gap-2">
|
||||
<div className="title-4xl-semi-bold text-text-primary">
|
||||
{t('marketplace.searchResultsFor')}
|
||||
</div>
|
||||
<div className="relative title-4xl-semi-bold text-saas-dify-blue-accessible">
|
||||
<span className="relative z-10">{searchPluginText || ''}</span>
|
||||
<span className="absolute bottom-0 left-0 right-0 h-3 bg-saas-dify-blue-accessible opacity-10" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SearchResultsHeader
|
||||
@@ -1,30 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/utils/classnames'
|
||||
import PluginTypeSwitch from './plugin-type-switch'
|
||||
import SearchBoxWrapper from './search-box/search-box-wrapper'
|
||||
|
||||
type StickySearchAndSwitchWrapperProps = {
|
||||
pluginTypeSwitchClassName?: string
|
||||
}
|
||||
|
||||
const StickySearchAndSwitchWrapper = ({
|
||||
pluginTypeSwitchClassName,
|
||||
}: StickySearchAndSwitchWrapperProps) => {
|
||||
const hasCustomTopClass = pluginTypeSwitchClassName?.includes('top-')
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-4 bg-background-body',
|
||||
hasCustomTopClass && 'sticky z-10',
|
||||
pluginTypeSwitchClassName,
|
||||
)}
|
||||
>
|
||||
<SearchBoxWrapper />
|
||||
<PluginTypeSwitch />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StickySearchAndSwitchWrapper
|
||||
@@ -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}
|
||||
/>
|
||||
{!isPluginsTab && <SearchBoxWrapper wrapperClassName="w-[360px] mx-0" inputClassName="p-0" />}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{
|
||||
|
||||
@@ -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,10 +195,27 @@
|
||||
"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.noPluginFound": "No plugin found",
|
||||
"marketplace.ourTopPicks": "Our top picks to get you started",
|
||||
"marketplace.partnerTip": "Verified by a Dify partner",
|
||||
"marketplace.pluginsResult": "{{num}} results",
|
||||
"marketplace.searchBreadcrumbMarketplace": "Marketplace",
|
||||
"marketplace.searchBreadcrumbSearch": "Search",
|
||||
"marketplace.searchDropdown.byAuthor": "by {{author}}",
|
||||
"marketplace.searchDropdown.enter": "Enter",
|
||||
"marketplace.searchDropdown.plugins": "Plugins",
|
||||
"marketplace.searchDropdown.showAllResults": "Show all search results",
|
||||
"marketplace.searchFilterAll": "All",
|
||||
"marketplace.searchFilterCreators": "Creators",
|
||||
"marketplace.searchFilterPlugins": "Plugins",
|
||||
"marketplace.searchFilterTags": "Tags",
|
||||
"marketplace.searchFilterTypes": "Types",
|
||||
"marketplace.searchResultsFor": "Results for",
|
||||
"marketplace.sortBy": "Sort by",
|
||||
"marketplace.sortOption.firstReleased": "First Released",
|
||||
"marketplace.sortOption.mostPopular": "Most Popular",
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
"autoUpdate.upgradeModePlaceholder.partial": "仅选定的插件将自动更新。目前未选择任何插件,因此不会自动更新任何插件。",
|
||||
"category.agents": "Agent 策略",
|
||||
"category.all": "全部",
|
||||
"category.allTypes": "所有类型",
|
||||
"category.bundles": "插件集",
|
||||
"category.datasources": "数据源",
|
||||
"category.extensions": "扩展",
|
||||
@@ -194,10 +195,27 @@
|
||||
"marketplace.difyMarketplace": "Dify 市场",
|
||||
"marketplace.discover": "探索",
|
||||
"marketplace.empower": "助力您的 AI 开发",
|
||||
"marketplace.featured": "精选",
|
||||
"marketplace.heroSubtitle": "使用社区构建的插件为您的 AI 开发提供动力。",
|
||||
"marketplace.heroTitle": "探索。扩展。构建。",
|
||||
"marketplace.installs": "次安装",
|
||||
"marketplace.moreFrom": "更多来自市场",
|
||||
"marketplace.noPluginFound": "未找到插件",
|
||||
"marketplace.ourTopPicks": "我们精选推荐",
|
||||
"marketplace.partnerTip": "此插件由 Dify 合作伙伴认证",
|
||||
"marketplace.pluginsResult": "{{num}} 个插件结果",
|
||||
"marketplace.searchBreadcrumbMarketplace": "市场",
|
||||
"marketplace.searchBreadcrumbSearch": "搜索",
|
||||
"marketplace.searchDropdown.byAuthor": "由 {{author}} 提供",
|
||||
"marketplace.searchDropdown.enter": "输入",
|
||||
"marketplace.searchDropdown.plugins": "插件",
|
||||
"marketplace.searchDropdown.showAllResults": "显示所有搜索结果",
|
||||
"marketplace.searchFilterAll": "全部",
|
||||
"marketplace.searchFilterCreators": "创作者",
|
||||
"marketplace.searchFilterPlugins": "插件",
|
||||
"marketplace.searchFilterTags": "标签",
|
||||
"marketplace.searchFilterTypes": "类型",
|
||||
"marketplace.searchResultsFor": "搜索结果",
|
||||
"marketplace.sortBy": "排序方式",
|
||||
"marketplace.sortOption.firstReleased": "首次发布",
|
||||
"marketplace.sortOption.mostPopular": "最受欢迎",
|
||||
|
||||
@@ -118,6 +118,7 @@
|
||||
"mermaid": "11.11.0",
|
||||
"mime": "4.1.0",
|
||||
"mitt": "3.0.1",
|
||||
"motion": "12.31.0",
|
||||
"negotiator": "1.0.0",
|
||||
"next": "16.1.5",
|
||||
"next-themes": "0.4.6",
|
||||
|
||||
BIN
web/public/marketplace/hero-bg.jpg
Normal file
BIN
web/public/marketplace/hero-bg.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 201 KiB |
27
web/public/marketplace/hero-gradient-noise.svg
Normal file
27
web/public/marketplace/hero-gradient-noise.svg
Normal file
@@ -0,0 +1,27 @@
|
||||
<svg width="1416" height="200" viewBox="0 0 1416 200" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_n_21362_44659)">
|
||||
<rect width="1416" height="200" fill="url(#paint0_linear_21362_44659)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_n_21362_44659" x="0" y="0" width="1416" height="200" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.83333331346511841 0.83333331346511841" stitchTiles="stitch" numOctaves="3" result="noise" seed="3192" />
|
||||
<feColorMatrix in="noise" type="luminanceToAlpha" result="alphaNoise" />
|
||||
<feComponentTransfer in="alphaNoise" result="coloredNoise1">
|
||||
<feFuncA type="discrete" tableValues="1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 "/>
|
||||
</feComponentTransfer>
|
||||
<feComposite operator="in" in2="shape" in="coloredNoise1" result="noise1Clipped" />
|
||||
<feFlood flood-color="rgba(0, 0, 0, 0.18)" result="color1Flood" />
|
||||
<feComposite operator="in" in2="noise1Clipped" in="color1Flood" result="color1" />
|
||||
<feMerge result="effect1_noise_21362_44659">
|
||||
<feMergeNode in="shape" />
|
||||
<feMergeNode in="color1" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_21362_44659" x1="708" y1="0" x2="708" y2="200" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-opacity="0.3"/>
|
||||
<stop offset="0.661631" stop-color="#0033FF" stop-opacity="0.3"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
Reference in New Issue
Block a user