Compare commits

...

6 Commits

Author SHA1 Message Date
yessenia
ccaccc8b34 feat: enhance templates marketplace with search functionality and template mapping 2026-02-05 18:30:49 +08:00
yessenia
96933e4349 feat: add templates marketplace functionality 2026-02-05 16:47:56 +08:00
yessenia
c73a932343 feat: plugin button 2026-02-05 13:59:04 +08:00
yessenia
49baf07f1f feat: layout opt 2026-02-04 17:53:36 +08:00
yessenia
189ef19a1d feat: layout opt 2026-02-04 16:32:28 +08:00
yessenia
53252d0395 feat: marketplace layout opt 2026-02-03 16:39:16 +08:00
38 changed files with 2557 additions and 277 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,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>
)
}

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

@@ -1,72 +1,170 @@
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
marketplaceNav?: React.ReactNode
}
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',
marketplaceNav,
}: 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, marketplaceNav ? 16 : 0])
const titleMarginTop = useTransform(smoothProgress, [0, 1], [marketplaceNav ? 80 : 0, 0])
const paddingTop = useTransform(smoothProgress, [0, 1], [marketplaceNav ? COLLAPSED_PADDING_TOP : 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})` }}
/>
{marketplaceNav}
{/* 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',
marginTop: titleMarginTop,
}}
>
<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>
)
}

View File

@@ -1,31 +1,33 @@
import type { SearchParams } from 'nuqs'
import { TanstackQueryInitializer } from '@/context/query-client'
import Description from './description'
import { cn } from '@/utils/classnames'
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.
*/
searchParams?: Promise<SearchParams>
/**
* Whether the marketplace is the platform marketplace.
*/
isMarketplacePlatform?: boolean
marketplaceNav?: React.ReactNode
}
const Marketplace = async ({
showInstallButton = true,
pluginTypeSwitchClassName,
searchParams,
isMarketplacePlatform = false,
marketplaceNav,
}: MarketplaceProps) => {
return (
<TanstackQueryInitializer>
<HydrateQueryClient searchParams={searchParams}>
<Description />
<StickySearchAndSwitchWrapper
pluginTypeSwitchClassName={pluginTypeSwitchClassName}
/>
<MarketplaceHeader descriptionClassName={cn('mx-12 mt-1', isMarketplacePlatform && 'top-0 mx-0 mt-0 rounded-none')} marketplaceNav={marketplaceNav} />
<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,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

View File

@@ -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,
)}
>

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 // 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>
))
)
})
}
</>
)

View File

@@ -1,42 +1,141 @@
'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'
import TemplateList from './template-list'
import TemplateSearchList from './template-search-list'
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 marketplaceData = useMarketplaceData()
const {
creationType,
isLoading,
} = marketplaceData
// Templates view
if (creationType === 'templates') {
const {
templateCollections,
templateCollectionTemplatesMap,
templates,
isSearchMode: isTemplateSearchMode,
} = marketplaceData
return (
<div
style={{ scrollbarGutter: 'stable' }}
className="relative flex grow flex-col bg-background-default-subtle px-12 py-2"
>
{
isLoading && (
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<Loading />
</div>
)
}
{
!isLoading && (
isTemplateSearchMode
? (
<TemplateSearchList templates={templates || []} />
)
: (
<TemplateList
templateCollections={templateCollections || []}
templateCollectionTemplatesMap={templateCollectionTemplatesMap || {}}
/>
)
)
}
</div>
)
}
// Plugins view (default)
const {
plugins,
pluginsTotal,
marketplaceCollections,
marketplaceCollectionPluginsMap,
isLoading,
isFetchingNextPage,
page,
} = useMarketplaceData()
} = marketplaceData
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">

View File

@@ -0,0 +1,188 @@
'use client'
import type { Template } from '../types'
import { useLocale } from '#i18n'
import Image from 'next/image'
import * as React from 'react'
import { useCallback, useMemo } from 'react'
import useTheme from '@/hooks/use-theme'
import { getLanguage } from '@/i18n-config/language'
import { cn } from '@/utils/classnames'
import { getMarketplaceUrl } from '@/utils/var'
type TemplateCardProps = {
template: Template
className?: string
}
// Number of tag icons to show before showing "+X"
const MAX_VISIBLE_TAGS = 7
// Soft background color palette for avatar
const AVATAR_BG_COLORS = [
'bg-components-icon-bg-red-soft',
'bg-components-icon-bg-orange-dark-soft',
'bg-components-icon-bg-yellow-soft',
'bg-components-icon-bg-green-soft',
'bg-components-icon-bg-teal-soft',
'bg-components-icon-bg-blue-light-soft',
'bg-components-icon-bg-blue-soft',
'bg-components-icon-bg-indigo-soft',
'bg-components-icon-bg-violet-soft',
'bg-components-icon-bg-pink-soft',
]
// Simple hash function to get consistent color per template
const getAvatarBgClass = (id: string): string => {
let hash = 0
for (let i = 0; i < id.length; i++) {
const char = id.charCodeAt(i)
hash = ((hash << 5) - hash) + char
hash = hash & hash
}
return AVATAR_BG_COLORS[Math.abs(hash) % AVATAR_BG_COLORS.length]
}
const TemplateCardComponent = ({
template,
className,
}: TemplateCardProps) => {
const locale = useLocale()
const { theme } = useTheme()
const { template_id, name, description, icon, tags, author, used_count, icon_background } = template as Template & { used_count?: number, icon_background?: string }
const isIconUrl = !!icon && /^(?:https?:)?\/\//.test(icon)
const avatarBgStyle = useMemo(() => {
// If icon_background is provided (hex or rgba), use it directly
if (icon_background)
return { backgroundColor: icon_background }
return undefined
}, [icon_background])
const avatarBgClass = useMemo(() => {
// Only use class-based color if no inline style
if (icon_background)
return ''
return getAvatarBgClass(template_id)
}, [icon_background, template_id])
const descriptionText = description[getLanguage(locale)] || description.en_US || ''
const handleClick = useCallback(() => {
const url = getMarketplaceUrl(`/templates/${author}/${name}`, {
theme,
language: locale,
templateId: template_id,
})
window.open(url, '_blank')
}, [author, name, theme, locale, template_id])
const visibleTags = tags?.slice(0, MAX_VISIBLE_TAGS) || []
const remainingTagsCount = tags ? Math.max(0, tags.length - MAX_VISIBLE_TAGS) : 0
// Format used count (e.g., 134000 -> "134k")
const formatUsedCount = (count?: number) => {
if (!count)
return null
if (count >= 1000)
return `${Math.floor(count / 1000)}k`
return String(count)
}
const formattedUsedCount = formatUsedCount(used_count)
return (
<div
className={cn(
'hover-bg-components-panel-on-panel-item-bg relative flex h-full cursor-pointer flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg pb-3 shadow-xs',
className,
)}
onClick={handleClick}
>
{/* Header */}
<div className="flex shrink-0 items-center gap-3 px-4 pb-2 pt-4">
{/* Avatar */}
<div
className={cn(
'flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-[10px] border-[0.5px] border-divider-regular p-1',
avatarBgClass,
)}
style={avatarBgStyle}
>
{isIconUrl
? (
<Image
src={icon}
alt={name}
width={24}
height={24}
className="h-6 w-6 object-contain"
/>
)
: (
<span className="text-2xl leading-[1.2]">{icon || '📄'}</span>
)}
</div>
{/* Title */}
<div className="flex min-w-0 flex-1 flex-col justify-center gap-0.5">
<p className="system-md-medium truncate text-text-primary">{name}</p>
<div className="system-xs-regular flex items-center gap-2 text-text-tertiary">
<span className="flex shrink-0 items-center gap-1">
<span>by</span>
<span className="truncate">{author}</span>
</span>
{formattedUsedCount && (
<>
<span className="shrink-0">·</span>
<span className="shrink-0">
{formattedUsedCount}
{' '}
used
</span>
</>
)}
</div>
</div>
</div>
{/* Description */}
<div className="shrink-0 px-4 pb-2 pt-1">
<p
className="system-xs-regular line-clamp-2 min-h-[32px] text-text-secondary"
title={descriptionText}
>
{descriptionText}
</p>
</div>
{/* Bottom Info Bar - Tags as icons */}
<div className="mt-auto flex min-h-7 shrink-0 items-center gap-1 px-4 py-1">
{tags && tags.length > 0 && (
<>
{visibleTags.map((tag, index) => (
<div
key={`${template_id}-tag-${index}`}
className="flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded-md border-[0.5px] border-effects-icon-border bg-background-default-dodge"
title={tag}
>
<span className="text-sm">{tag}</span>
</div>
))}
{remainingTagsCount > 0 && (
<div className="flex items-center justify-center p-0.5">
<span className="system-xs-regular text-text-tertiary">
+
{remainingTagsCount}
</span>
</div>
)}
</>
)}
</div>
</div>
)
}
const TemplateCard = React.memo(TemplateCardComponent)
export default TemplateCard

View File

@@ -0,0 +1,129 @@
'use client'
import type { Template, TemplateCollection } from '../types'
import { useLocale, useTranslation } from '#i18n'
import { RiArrowRightSLine } from '@remixicon/react'
import { getLanguage } from '@/i18n-config/language'
import Empty from '../empty'
import Carousel from './carousel'
import TemplateCard from './template-card'
type TemplateListProps = {
templateCollections: TemplateCollection[]
templateCollectionTemplatesMap: Record<string, Template[]>
cardContainerClassName?: string
}
const FEATURED_COLLECTION_NAME = 'featured'
const GRID_DISPLAY_LIMIT = 8
const TemplateList = ({
templateCollections,
templateCollectionTemplatesMap,
cardContainerClassName,
}: TemplateListProps) => {
const { t } = useTranslation()
const locale = useLocale()
const renderTemplateCard = (template: Template) => {
return (
<TemplateCard
key={template.template_id}
template={template}
/>
)
}
const renderFeaturedCarousel = (collection: TemplateCollection, templates: Template[]) => {
// Featured collection: 2-row carousel with auto-play
const rows: Template[][] = []
for (let i = 0; i < templates.length; i += 2) {
rows.push(templates.slice(i, i + 2))
}
return (
<Carousel
className={cardContainerClassName}
showNavigation={templates.length > 8}
showPagination={templates.length > 8}
autoPlay={templates.length > 8}
autoPlayInterval={5000}
>
{rows.map(columnTemplates => (
<div
key={`column-${columnTemplates[0]?.template_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' }}
>
{columnTemplates.map(template => (
<div key={template.template_id}>
{renderTemplateCard(template)}
</div>
))}
</div>
))}
</Carousel>
)
}
const renderGridCollection = (collection: TemplateCollection, templates: Template[]) => {
const displayTemplates = templates.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">
{displayTemplates.map(template => (
<div key={template.template_id}>
{renderTemplateCard(template)}
</div>
))}
</div>
)
}
const collectionsWithTemplates = templateCollections.filter((collection) => {
return templateCollectionTemplatesMap[collection.name]?.length
})
if (collectionsWithTemplates.length === 0) {
return <Empty />
}
return (
<>
{
collectionsWithTemplates.map((collection) => {
const templates = templateCollectionTemplatesMap[collection.name]
const isFeaturedCollection = collection.name === FEATURED_COLLECTION_NAME
const showViewMore = collection.searchable && (isFeaturedCollection || templates.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"
>
{t('marketplace.viewMore', { ns: 'plugin' })}
<RiArrowRightSLine className="h-4 w-4" />
</div>
)}
</div>
{isFeaturedCollection
? renderFeaturedCarousel(collection, templates)
: renderGridCollection(collection, templates)}
</div>
)
})
}
</>
)
}
export default TemplateList

View File

@@ -0,0 +1,27 @@
'use client'
import type { Template } from '../types'
import Empty from '../empty'
import TemplateCard from './template-card'
type TemplateSearchListProps = {
templates: Template[]
}
const TemplateSearchList = ({ templates }: TemplateSearchListProps) => {
if (templates.length === 0) {
return <Empty />
}
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{templates.map(template => (
<div key={template.template_id}>
<TemplateCard template={template} />
</div>
))}
</div>
)
}
export default TemplateSearchList

View File

@@ -0,0 +1,21 @@
'use client'
import { useMarketplaceSearchMode } from './atoms'
import { Description } from './description'
import SearchResultsHeader from './search-results-header'
type MarketplaceHeaderProps = {
descriptionClassName?: string
marketplaceNav?: React.ReactNode
}
const MarketplaceHeader = ({ descriptionClassName, marketplaceNav }: MarketplaceHeaderProps) => {
const isSearchMode = useMarketplaceSearchMode()
if (isSearchMode)
return <SearchResultsHeader />
return <Description className={descriptionClassName} marketplaceNav={marketplaceNav} />
}
export default MarketplaceHeader

View 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,
}

View File

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

View File

@@ -1,8 +1,8 @@
import type { PluginsSearchParams } from './types'
import type { PluginsSearchParams, TemplateSearchParams } from './types'
import type { MarketPlaceInputs } from '@/contract/router'
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
import { marketplaceQuery } from '@/service/client'
import { getMarketplaceCollectionsAndPlugins, getMarketplacePlugins } from './utils'
import { getMarketplaceCollectionsAndPlugins, getMarketplacePlugins, getMarketplaceTemplateCollectionsAndTemplates, getMarketplaceTemplates } from './utils'
export function useMarketplaceCollectionsAndPlugins(
collectionsParams: MarketPlaceInputs['collections']['query'],
@@ -13,6 +13,15 @@ export function useMarketplaceCollectionsAndPlugins(
})
}
export function useMarketplaceTemplateCollectionsAndTemplates(
query?: { page?: number, page_size?: number, condition?: string },
) {
return useQuery({
queryKey: marketplaceQuery.templateCollections.list.queryKey({ input: { query } }),
queryFn: ({ signal }) => getMarketplaceTemplateCollectionsAndTemplates(query, { signal }),
})
}
export function useMarketplacePlugins(
queryParams: PluginsSearchParams | undefined,
) {
@@ -33,3 +42,23 @@ export function useMarketplacePlugins(
enabled: queryParams !== undefined,
})
}
export function useMarketplaceTemplates(
queryParams: TemplateSearchParams | undefined,
) {
return useInfiniteQuery({
queryKey: marketplaceQuery.templates.searchAdvanced.queryKey({
input: {
body: queryParams!,
},
}),
queryFn: ({ pageParam = 1, signal }) => getMarketplaceTemplates(queryParams, pageParam, signal),
getNextPageParam: (lastPage) => {
const nextPage = lastPage.page + 1
const loaded = lastPage.page * lastPage.page_size
return loaded < (lastPage.total || 0) ? nextPage : undefined
},
initialPageParam: 1,
enabled: queryParams !== undefined,
})
}

View File

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

View File

@@ -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}
/>
{

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
export { default as MarketplaceTrigger } from './marketplace'
export { default as ToolSelectorTrigger } from './tool-selector'

View File

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

View File

@@ -1,13 +1,18 @@
import type { PluginsSearchParams } from './types'
import type { PluginsSearchParams, TemplateSearchParams } from './types'
import { useDebounce } from 'ahooks'
import { useSearchParams } from 'next/navigation'
import { useCallback, useMemo } from 'react'
import { useActivePluginType, useFilterPluginTags, useMarketplaceSearchMode, useMarketplaceSortValue, useSearchPluginText } from './atoms'
import { PLUGIN_TYPE_SEARCH_MAP } from './constants'
import { useMarketplaceContainerScroll } from './hooks'
import { useMarketplaceCollectionsAndPlugins, useMarketplacePlugins } from './query'
import { getCollectionsParams, getMarketplaceListFilterType } from './utils'
import { useMarketplaceCollectionsAndPlugins, useMarketplacePlugins, useMarketplaceTemplateCollectionsAndTemplates, useMarketplaceTemplates } from './query'
import { getCollectionsParams, getMarketplaceListFilterType, mapTemplateDetailToTemplate } from './utils'
export function useMarketplaceData() {
/**
* Hook for plugins marketplace data
* Only fetches plugins-related data
*/
export function usePluginsMarketplaceData() {
const [searchPluginTextOriginal] = useSearchPluginText()
const searchPluginText = useDebounce(searchPluginTextOriginal, { wait: 500 })
const [filterPluginTags] = useFilterPluginTags()
@@ -53,3 +58,138 @@ export function useMarketplaceData() {
isFetchingNextPage,
}
}
/**
* Hook for templates marketplace data
* Only fetches templates-related data
*/
export function useTemplatesMarketplaceData() {
// Reuse existing atoms for search and sort
const [searchTextOriginal] = useSearchPluginText()
const searchText = useDebounce(searchTextOriginal, { wait: 500 })
const [activeCategory] = useActivePluginType()
// Template collections query (for non-search mode)
const templateCollectionsQuery = useMarketplaceTemplateCollectionsAndTemplates()
// Sort value
const sort = useMarketplaceSortValue()
// Search mode: when there's search text or non-default category
const isSearchMode = !!searchText || (activeCategory !== PLUGIN_TYPE_SEARCH_MAP.all)
// Build query params for search mode
const queryParams = useMemo((): TemplateSearchParams | undefined => {
if (!isSearchMode)
return undefined
return {
query: searchText,
categories: activeCategory === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : [activeCategory],
sort_by: sort.sortBy,
sort_order: sort.sortOrder,
}
}, [isSearchMode, searchText, activeCategory, sort])
// Templates search query (for search mode)
const templatesQuery = useMarketplaceTemplates(queryParams)
const { hasNextPage, fetchNextPage, isFetching, isFetchingNextPage } = templatesQuery
// Pagination handler
const handlePageChange = useCallback(() => {
if (hasNextPage && !isFetching)
fetchNextPage()
}, [fetchNextPage, hasNextPage, isFetching])
// Scroll pagination
useMarketplaceContainerScroll(handlePageChange)
// Compute flat templates list from collection map (for non-search mode)
const { collectionTemplates, collectionTemplatesTotal } = useMemo(() => {
const templateCollectionTemplatesMap = templateCollectionsQuery.data?.templateCollectionTemplatesMap
if (!templateCollectionTemplatesMap) {
return { collectionTemplates: undefined, collectionTemplatesTotal: 0 }
}
const allTemplates = Object.values(templateCollectionTemplatesMap).flat()
// Deduplicate templates by template_id
const uniqueTemplates = allTemplates.filter(
(template, index, self) => index === self.findIndex(t => t.template_id === template.template_id),
)
return {
collectionTemplates: uniqueTemplates,
collectionTemplatesTotal: uniqueTemplates.length,
}
}, [templateCollectionsQuery.data?.templateCollectionTemplatesMap])
const searchTemplates = useMemo(() => {
const rawTemplates = templatesQuery.data?.pages.flatMap(page => page.templates) || []
return rawTemplates.map(mapTemplateDetailToTemplate)
}, [templatesQuery.data])
// Return search results when in search mode, otherwise return collection data
if (isSearchMode) {
return {
isSearchMode,
templateCollections: undefined,
templateCollectionTemplatesMap: undefined,
templates: searchTemplates,
templatesTotal: templatesQuery.data?.pages[0]?.total,
page: templatesQuery.data?.pages.length || 1,
isLoading: templatesQuery.isLoading,
isFetchingNextPage,
}
}
return {
isSearchMode,
templateCollections: templateCollectionsQuery.data?.templateCollections,
templateCollectionTemplatesMap: templateCollectionsQuery.data?.templateCollectionTemplatesMap,
templates: collectionTemplates,
templatesTotal: collectionTemplatesTotal,
page: 1,
isLoading: templateCollectionsQuery.isLoading,
isFetchingNextPage: false,
}
}
/**
* Main hook that routes to appropriate data based on creationType
* Returns either plugins or templates data based on URL parameter
*/
export function useMarketplaceData() {
const searchParams = useSearchParams()
const creationType = (searchParams.get('creationType') || 'plugins') as 'plugins' | 'templates'
const pluginsData = usePluginsMarketplaceData()
const templatesData = useTemplatesMarketplaceData()
if (creationType === 'templates') {
return {
creationType,
isSearchMode: templatesData.isSearchMode,
// Templates-specific fields
templateCollections: templatesData.templateCollections,
templateCollectionTemplatesMap: templatesData.templateCollectionTemplatesMap,
templates: templatesData.templates,
templatesTotal: templatesData.templatesTotal,
page: templatesData.page,
isLoading: templatesData.isLoading,
isFetchingNextPage: templatesData.isFetchingNextPage,
}
}
// Default: plugins
return {
creationType,
isSearchMode: false, // plugins uses useMarketplaceSearchMode separately
// Plugins-specific fields
marketplaceCollections: pluginsData.marketplaceCollections,
marketplaceCollectionPluginsMap: pluginsData.marketplaceCollectionPluginsMap,
plugins: pluginsData.plugins,
pluginsTotal: pluginsData.pluginsTotal,
page: pluginsData.page,
isLoading: pluginsData.isLoading,
isFetchingNextPage: pluginsData.isFetchingNextPage,
}
}

View File

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

View File

@@ -56,4 +56,131 @@ export type SearchParams = {
q?: string
tags?: string
category?: string
creationType?: string
}
export type TemplateCollection = {
id: string
name: string
label: Record<string, string>
description: Record<string, string>
conditions: string[]
searchable: boolean
search_params?: SearchParamsFromCollection
created_at?: string
updated_at?: string
}
export type Template = {
template_id: string
name: string
description: Record<string, string>
icon: string
tags: string[]
author: string
created_at: string
updated_at: string
}
export type CreateTemplateCollectionRequest = {
name: string
description: Record<string, string>
label: Record<string, string>
conditions: string[]
searchable: boolean
search_params: SearchParamsFromCollection
}
export type GetCollectionTemplatesRequest = {
categories?: string[]
exclude?: string[]
limit?: number
}
export type AddTemplateToCollectionRequest = {
template_id: string
}
export type BatchAddTemplatesToCollectionRequest = {
template_id: string
}[]
// Creator types
export type Creator = {
email: string
name: string
display_name: string
unique_handle: string
display_email: string
description: string
avatar: string
social_links: string[]
status: 'active' | 'inactive'
public?: boolean
plugin_count?: number
template_count?: number
created_at: string
updated_at: string
}
export type CreatorSearchParams = {
query?: string
page?: number
page_size?: number
categories?: string[]
sort_by?: string
sort_order?: string
}
export type CreatorSearchResponse = {
creators: Creator[]
total: number
}
export type SyncCreatorProfileRequest = {
email: string
name?: string
display_name?: string
unique_handle: string
display_email?: string
description?: string
avatar?: string
social_links?: string[]
status?: 'active' | 'inactive'
}
// Template Detail (full template info from API)
export type TemplateDetail = {
id: string
publisher_type: 'individual' | 'organization'
publisher_unique_handle: string
creator_email: string
template_name: string
icon: string
icon_background: string
icon_file_key: string
dsl_file_key: string
categories: string[]
overview: string
readme: string
partner_link: string
status: 'published' | 'draft' | 'pending' | 'rejected'
review_comment: string
created_at: string
updated_at: string
}
export type TemplatesListResponse = {
templates: TemplateDetail[]
total: number
}
export type TemplateSearchParams = {
query?: string
page?: number
page_size?: number
categories?: string[]
sort_by?: string
sort_order?: string
languages?: string[]
}

View File

@@ -3,6 +3,10 @@ import type {
CollectionsAndPluginsSearchParams,
MarketplaceCollection,
PluginsSearchParams,
Template,
TemplateCollection,
TemplateDetail,
TemplateSearchParams,
} from '@/app/components/plugins/marketplace/types'
import type { Plugin } from '@/app/components/plugins/types'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
@@ -112,6 +116,67 @@ export const getMarketplaceCollectionsAndPlugins = async (
}
}
export function mapTemplateDetailToTemplate(template: TemplateDetail): Template {
const descriptionText = template.overview || template.readme || ''
return {
template_id: template.id,
name: template.template_name,
description: {
en_US: descriptionText,
zh_Hans: descriptionText,
},
icon: template.icon || '',
tags: template.categories || [],
author: template.publisher_unique_handle || template.creator_email || '',
created_at: template.created_at,
updated_at: template.updated_at,
}
}
export const getMarketplaceTemplateCollectionsAndTemplates = async (
query?: { page?: number, page_size?: number, condition?: string },
options?: MarketplaceFetchOptions,
) => {
let templateCollections: TemplateCollection[] = []
let templateCollectionTemplatesMap: Record<string, Template[]> = {}
try {
const res = await marketplaceClient.templateCollections.list({
query: {
...query,
page: 1,
page_size: 100,
},
}, {
signal: options?.signal,
})
templateCollections = res.data?.collections || []
await Promise.all(templateCollections.map(async (collection) => {
try {
const templatesRes = await marketplaceClient.templateCollections.getTemplates({
params: { collectionName: collection.name },
body: { limit: 20 },
}, { signal: options?.signal })
const templatesData = templatesRes.data?.templates || []
templateCollectionTemplatesMap[collection.name] = templatesData.map(mapTemplateDetailToTemplate)
}
catch {
templateCollectionTemplatesMap[collection.name] = []
}
}))
}
catch {
templateCollections = []
templateCollectionTemplatesMap = {}
}
return {
templateCollections,
templateCollectionTemplatesMap,
}
}
export const getMarketplacePlugins = async (
queryParams: PluginsSearchParams | undefined,
pageParam: number,
@@ -203,3 +268,61 @@ export function getCollectionsParams(category: ActivePluginType): CollectionsAnd
type: getMarketplaceListFilterType(category),
}
}
export const getMarketplaceTemplates = async (
queryParams: TemplateSearchParams | undefined,
pageParam: number,
signal?: AbortSignal,
): Promise<{
templates: TemplateDetail[]
total: number
page: number
page_size: number
}> => {
if (!queryParams) {
return {
templates: [] as TemplateDetail[],
total: 0,
page: 1,
page_size: 40,
}
}
const {
query,
sort_by,
sort_order,
categories,
languages,
page_size = 40,
} = queryParams
try {
const res = await marketplaceClient.templates.searchAdvanced({
body: {
page: pageParam,
page_size,
query,
sort_by,
sort_order,
categories,
languages,
},
}, { signal })
return {
templates: res.data?.templates || [],
total: res.data?.total || 0,
page: pageParam,
page_size,
}
}
catch {
return {
templates: [],
total: 0,
page: pageParam,
page_size,
}
}
}

View File

@@ -2,13 +2,11 @@
import type { Dependency, PluginDeclaration, PluginManifestInMarket } from '../types'
import {
RiBookOpenLine,
RiDragDropLine,
RiEqualizer2Line,
} from '@remixicon/react'
import { useBoolean } from 'ahooks'
import { noop } from 'es-toolkit/function'
import Link from 'next/link'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
@@ -17,22 +15,22 @@ import Tooltip from '@/app/components/base/tooltip'
import ReferenceSettingModal from '@/app/components/plugins/reference-setting-modal'
import { MARKETPLACE_API_PREFIX, SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS } from '@/config'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useDocLink } from '@/context/i18n'
import useDocumentTitle from '@/hooks/use-document-title'
import { usePluginInstallation } from '@/hooks/use-query-params'
import { fetchBundleInfoFromMarketPlace, fetchManifestFromMarketPlace } from '@/service/plugins'
import { sleep } from '@/utils'
import { cn } from '@/utils/classnames'
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,
} from './context'
import DebugInfo from './debug-info'
import InstallPluginDropdown from './install-plugin-dropdown'
import { SubmitRequestDropdown } from './nav-operations'
import PluginTasks from './plugin-tasks'
import useReferenceSetting from './use-reference-setting'
import { useUploader } from './use-uploader'
@@ -46,7 +44,6 @@ const PluginPage = ({
marketplace,
}: PluginPageProps) => {
const { t } = useTranslation()
const docLink = useDocLink()
useDocumentTitle(t('metadata.title', { ns: 'plugin' }))
// Use nuqs hook for installation state
@@ -140,55 +137,20 @@ 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">
{
isExploringMarketplace && (
<>
<Link
href="https://github.com/langgenius/dify-plugins/issues/new?template=plugin_request.yaml"
target="_blank"
>
<Button
variant="ghost"
className="text-text-tertiary"
>
{t('requestAPlugin', { ns: 'plugin' })}
</Button>
</Link>
<Link
href={docLink('/develop-plugin/publishing/marketplace-listing/release-to-dify-marketplace')}
target="_blank"
>
<Button
className="px-3"
variant="secondary-accent"
>
<RiBookOpenLine className="mr-1 h-4 w-4" />
{t('publishPlugins', { ns: 'plugin' })}
</Button>
</Link>
<div className="mx-1 h-3.5 w-[1px] shrink-0 bg-divider-regular"></div>
</>
)
}
{isExploringMarketplace && <SubmitRequestDropdown />}
<PluginTasks />
{canManagement && (
<InstallPluginDropdown

View File

@@ -0,0 +1,119 @@
'use client'
import { RiBookOpenLine, RiGithubLine, RiLayoutGridLine, RiPuzzle2Line } from '@remixicon/react'
import Link from 'next/link'
import { useSearchParams } from 'next/navigation'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
import Button, { buttonVariants } from '@/app/components/base/button'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { useDocLink } from '@/context/i18n'
import { cn } from '@/utils/classnames'
type DropdownItemProps = {
href: string
icon: React.ReactNode
text: string
onClick: () => void
}
const DropdownItem = ({ href, icon, text, onClick }: DropdownItemProps) => (
<Link
href={href}
target="_blank"
className="flex items-center gap-2 rounded-lg px-3 py-2 text-text-secondary hover:bg-state-base-hover hover:text-text-primary"
onClick={onClick}
>
{icon}
<span className="system-sm-medium">{text}</span>
</Link>
)
export const SubmitRequestDropdown = () => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const docLink = useDocLink()
return (
<PortalToFollowElem
placement="bottom-start"
offset={4}
open={open}
onOpenChange={setOpen}
>
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
<Button
variant="ghost"
className={cn(
'flex items-center gap-1 px-3 py-2 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
open && 'bg-state-base-hover text-text-secondary',
)}
>
<span className="system-sm-medium">
{t('requestSubmitPlugin', { ns: 'plugin' })}
</span>
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[1000]">
<div className="min-w-[200px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-sm">
<DropdownItem
href="https://github.com/langgenius/dify-plugins/issues/new?template=plugin_request.yaml"
icon={<RiGithubLine className="h-4 w-4 shrink-0" />}
text={t('requestAPlugin', { ns: 'plugin' })}
onClick={() => setOpen(false)}
/>
<DropdownItem
href={docLink('/develop-plugin/publishing/marketplace-listing/release-to-dify-marketplace')}
icon={<RiBookOpenLine className="h-4 w-4 shrink-0" />}
text={t('publishPlugins', { ns: 'plugin' })}
onClick={() => setOpen(false)}
/>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export const CreationTypeTabs = () => {
const { t } = useTranslation()
const searchParams = useSearchParams()
const creationType = searchParams.get('creationType') || 'plugins'
return (
<div className="flex items-center gap-1">
<Link
href="/?creationType=plugins"
className={cn(
buttonVariants({ variant: 'ghost' }),
'flex items-center gap-1 px-3 py-2 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
creationType === 'plugins' && 'bg-state-base-hover text-text-secondary',
)}
>
<RiPuzzle2Line className="h-4 w-4 shrink-0" />
<span className="system-sm-medium">
{t('plugins', { ns: 'plugin' })}
</span>
</Link>
<Link
href="/?creationType=templates"
className={cn(
buttonVariants({ variant: 'ghost' }),
'flex items-center gap-1 px-3 py-2 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
creationType === 'templates' && 'bg-state-base-hover text-text-secondary',
)}
>
<RiLayoutGridLine className="h-4 w-4 shrink-0" />
<span className="system-sm-medium">
{t('templates', { ns: 'plugin' })}
</span>
<Badge className="ml-1 h-4 rounded-[4px] border-none bg-saas-dify-blue-accessible px-1 text-[10px] font-bold leading-[14px] text-text-primary-on-surface">
NEW
</Badge>
</Link>
</div>
)
}

View File

@@ -1,4 +1,20 @@
import type { CollectionsAndPluginsSearchParams, MarketplaceCollection, PluginsSearchParams } from '@/app/components/plugins/marketplace/types'
import type {
AddTemplateToCollectionRequest,
BatchAddTemplatesToCollectionRequest,
CollectionsAndPluginsSearchParams,
CreateTemplateCollectionRequest,
Creator,
CreatorSearchParams,
CreatorSearchResponse,
GetCollectionTemplatesRequest,
MarketplaceCollection,
PluginsSearchParams,
SyncCreatorProfileRequest,
TemplateCollection,
TemplateDetail,
TemplateSearchParams,
TemplatesListResponse,
} from '@/app/components/plugins/marketplace/types'
import type { Plugin, PluginsFromMarketplaceResponse } from '@/app/components/plugins/types'
import { type } from '@orpc/contract'
import { base } from './base'
@@ -54,3 +70,320 @@ export const searchAdvancedContract = base
body: Omit<PluginsSearchParams, 'type'>
}>())
.output(type<{ data: PluginsFromMarketplaceResponse }>())
export const templateCollectionsContract = base
.route({
path: '/template-collections',
method: 'GET',
})
.input(
type<{
query?: {
page?: number
page_size?: number
condition?: string
}
}>(),
)
.output(
type<{
data?: {
collections?: TemplateCollection[]
has_more?: boolean
limit?: number
page?: number
total?: number
}
}>(),
)
export const createTemplateCollectionContract = base
.route({
path: '/template-collections',
method: 'POST',
})
.input(
type<{
body: CreateTemplateCollectionRequest
}>(),
)
.output(type<TemplateCollection>())
export const getTemplateCollectionContract = base
.route({
path: '/template-collections/{collectionName}',
method: 'GET',
})
.input(
type<{
params: {
collectionName: string
}
}>(),
)
.output(type<TemplateCollection>())
export const deleteTemplateCollectionContract = base
.route({
path: '/template-collections/{collectionName}',
method: 'DELETE',
})
.input(
type<{
params: {
collectionName: string
}
}>(),
)
.output(type<void>())
export const getCollectionTemplatesContract = base
.route({
path: '/template-collections/{collectionName}/templates',
method: 'POST',
})
.input(
type<{
params: {
collectionName: string
}
body?: GetCollectionTemplatesRequest
}>(),
)
.output(
type<{
data?: TemplatesListResponse
}>(),
)
export const addTemplateToCollectionContract = base
.route({
path: '/template-collections/{collectionName}/templates',
method: 'PUT',
})
.input(
type<{
params: {
collectionName: string
}
body: AddTemplateToCollectionRequest
}>(),
)
.output(type<void>())
export const batchAddTemplatesToCollectionContract = base
.route({
path: '/template-collections/{collectionName}/templates/batch-add',
method: 'POST',
})
.input(
type<{
params: {
collectionName: string
}
body: BatchAddTemplatesToCollectionRequest
}>(),
)
.output(type<void>())
export const clearCollectionTemplatesContract = base
.route({
path: '/template-collections/{collectionName}/templates/clear',
method: 'PUT',
})
.input(
type<{
params: {
collectionName: string
}
}>(),
)
.output(type<void>())
// Creators contracts
export const getCreatorByHandleContract = base
.route({
path: '/creators/{uniqueHandle}',
method: 'GET',
})
.input(
type<{
params: {
uniqueHandle: string
}
}>(),
)
.output(
type<{
data?: {
creator?: Creator
}
}>(),
)
export const getCreatorAvatarContract = base
.route({
path: '/creators/{uniqueHandle}/avatar',
method: 'GET',
})
.input(
type<{
params: {
uniqueHandle: string
}
}>(),
)
.output(type<Blob>())
export const syncCreatorProfileContract = base
.route({
path: '/creators/sync/profile',
method: 'POST',
})
.input(
type<{
body: SyncCreatorProfileRequest
}>(),
)
.output(
type<{
data?: {
creator?: Creator
}
}>(),
)
export const syncCreatorAvatarContract = base
.route({
path: '/creators/sync/avatar',
method: 'POST',
})
.input(
type<{
body: FormData
}>(),
)
.output(type<void>())
export const searchCreatorsAdvancedContract = base
.route({
path: '/creators/search/advanced',
method: 'POST',
})
.input(
type<{
body: CreatorSearchParams
}>(),
)
.output(
type<{
data?: CreatorSearchResponse
}>(),
)
// Templates public endpoints
export const getTemplatesListContract = base
.route({
path: '/templates',
method: 'GET',
})
.input(
type<{
query?: {
page?: number
page_size?: number
categories?: string
}
}>(),
)
.output(
type<{
data?: TemplatesListResponse
}>(),
)
export const getTemplateByIdContract = base
.route({
path: '/templates/{templateId}',
method: 'GET',
})
.input(
type<{
params: {
templateId: string
}
}>(),
)
.output(
type<{
data?: TemplateDetail
}>(),
)
export const getTemplateDslFileContract = base
.route({
path: '/templates/{templateId}/file',
method: 'GET',
})
.input(
type<{
params: {
templateId: string
}
}>(),
)
.output(type<Blob>())
export const searchTemplatesBasicContract = base
.route({
path: '/templates/search/basic',
method: 'POST',
})
.input(
type<{
body: TemplateSearchParams
}>(),
)
.output(
type<{
data?: TemplatesListResponse
}>(),
)
export const searchTemplatesAdvancedContract = base
.route({
path: '/templates/search/advanced',
method: 'POST',
})
.input(
type<{
body: TemplateSearchParams
}>(),
)
.output(
type<{
data?: TemplatesListResponse
}>(),
)
export const getPublisherTemplatesContract = base
.route({
path: '/templates/publisher/{uniqueHandle}',
method: 'GET',
})
.input(
type<{
params: {
uniqueHandle: string
}
query?: {
page?: number
page_size?: number
}
}>(),
)
.output(
type<{
data?: TemplatesListResponse
}>(),
)

View File

@@ -2,12 +2,60 @@ import type { InferContractRouterInputs } from '@orpc/contract'
import { bindPartnerStackContract, invoicesContract } from './console/billing'
import { systemFeaturesContract } from './console/system'
import { trialAppDatasetsContract, trialAppInfoContract, trialAppParametersContract, trialAppWorkflowsContract } from './console/try-app'
import { collectionPluginsContract, collectionsContract, searchAdvancedContract } from './marketplace'
import {
addTemplateToCollectionContract,
batchAddTemplatesToCollectionContract,
clearCollectionTemplatesContract,
collectionPluginsContract,
collectionsContract,
createTemplateCollectionContract,
deleteTemplateCollectionContract,
getCollectionTemplatesContract,
getCreatorAvatarContract,
getCreatorByHandleContract,
getPublisherTemplatesContract,
getTemplateByIdContract,
getTemplateCollectionContract,
getTemplateDslFileContract,
getTemplatesListContract,
searchAdvancedContract,
searchCreatorsAdvancedContract,
searchTemplatesAdvancedContract,
searchTemplatesBasicContract,
syncCreatorAvatarContract,
syncCreatorProfileContract,
templateCollectionsContract,
} from './marketplace'
export const marketplaceRouterContract = {
collections: collectionsContract,
collectionPlugins: collectionPluginsContract,
searchAdvanced: searchAdvancedContract,
templateCollections: {
list: templateCollectionsContract,
create: createTemplateCollectionContract,
get: getTemplateCollectionContract,
delete: deleteTemplateCollectionContract,
getTemplates: getCollectionTemplatesContract,
addTemplate: addTemplateToCollectionContract,
batchAddTemplates: batchAddTemplatesToCollectionContract,
clearTemplates: clearCollectionTemplatesContract,
},
creators: {
getByHandle: getCreatorByHandleContract,
getAvatar: getCreatorAvatarContract,
syncProfile: syncCreatorProfileContract,
syncAvatar: syncCreatorAvatarContract,
searchAdvanced: searchCreatorsAdvancedContract,
},
templates: {
list: getTemplatesListContract,
getById: getTemplateByIdContract,
getDslFile: getTemplateDslFileContract,
searchBasic: searchTemplatesBasicContract,
searchAdvanced: searchTemplatesAdvancedContract,
getPublisherTemplates: getPublisherTemplatesContract,
},
}
export type MarketPlaceInputs = InferContractRouterInputs<typeof marketplaceRouterContract>

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,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",
@@ -210,6 +228,8 @@
"pluginInfoModal.release": "Release",
"pluginInfoModal.repository": "Repository",
"pluginInfoModal.title": "Plugin info",
"plugins": "Plugins",
"templates": "Templates",
"privilege.admins": "Admins",
"privilege.everyone": "Everyone",
"privilege.noone": "No one",
@@ -222,6 +242,7 @@
"readmeInfo.noReadmeAvailable": "No README available",
"readmeInfo.title": "README",
"requestAPlugin": "Request a plugin",
"requestSubmitPlugin": "Request / Submit",
"search": "Search",
"searchCategories": "Search Categories",
"searchInMarketplace": "Search in Marketplace",

View File

@@ -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": "最受欢迎",
@@ -210,6 +228,8 @@
"pluginInfoModal.release": "发布版本",
"pluginInfoModal.repository": "仓库",
"pluginInfoModal.title": "插件信息",
"plugins": "插件",
"templates": "模板",
"privilege.admins": "管理员",
"privilege.everyone": "所有人",
"privilege.noone": "无人",
@@ -222,6 +242,7 @@
"readmeInfo.noReadmeAvailable": "README 文档不可用",
"readmeInfo.title": "README",
"requestAPlugin": "申请插件",
"requestSubmitPlugin": "申请并发布插件",
"search": "搜索",
"searchCategories": "搜索类别",
"searchInMarketplace": "在 Marketplace 中搜索",

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

View 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