refactor(web): split marketplace home trending and share catalog constants

Break the trending banner into navigation and slide modules with shared
home constants, hoist the marketplace container id and partner-collection
constants for reuse across the catalog, and drop the i18n keys the
redesigned home no longer renders.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
CodingOnStar 2026-08-16 15:04:39 +08:00
parent 66f63a074b
commit 0fc3791bb6
42 changed files with 706 additions and 639 deletions

View File

@ -1,5 +1,6 @@
import type { SearchParams } from 'nuqs'
import AccountSection from '@/app/components/main-nav/components/account-section'
import { MARKETPLACE_CONTAINER_ID } from '@/app/components/plugins/marketplace/constants'
import { EmbeddedMarketplace } from '@/app/components/plugins/marketplace/embedded'
import { fetchPluginBanners } from '@/app/components/plugins/marketplace/home/banners'
import { HydrateQueryClient } from '@/app/components/plugins/marketplace/hydration-server'
@ -16,7 +17,7 @@ const MarketplacePage = async ({ searchParams }: MarketplacePageProps) => {
return (
<div
id="marketplace-container"
id={MARKETPLACE_CONTAINER_ID}
className="flex h-full min-h-0 flex-col overflow-y-auto bg-background-default"
>
<MarketplaceInstallPermissionProvider>

View File

@ -1,3 +1,4 @@
import { MARKETPLACE_CONTAINER_ID } from '@/app/components/plugins/marketplace/constants'
import { EmbeddedTemplatesMarketplace } from '@/app/components/plugins/marketplace/templates'
import { isTemplateCategory } from '@/app/components/plugins/marketplace/templates/categories'
import { getLocaleOnServer } from '@/i18n-config/server'
@ -51,7 +52,7 @@ export default async function TemplatesPage({ params, searchParams }: TemplatesP
return (
<div
id="marketplace-container"
id={MARKETPLACE_CONTAINER_ID}
className="flex h-full min-h-0 flex-col overflow-y-auto bg-background-default"
>
<EmbeddedTemplatesMarketplace

View File

@ -5,6 +5,12 @@ export const DEFAULT_SORT = {
sortOrder: 'DESC',
}
/**
* DOM id of the marketplace scroll container. The route components render it
* and the scroll/viewport observers below the marketplace tree look it up.
*/
export const MARKETPLACE_CONTAINER_ID = 'marketplace-container'
export const SCROLL_BOTTOM_THRESHOLD = 100
export const PLUGIN_TYPE_SEARCH_MAP = {

View File

@ -7,6 +7,7 @@ import { useLocale, useTranslation } from '#i18n'
import Divider from '@/app/components/base/divider'
import { DifyLogo } from '@/app/components/base/logo/dify-logo'
import { SubmitRequestDropdown } from '@/app/components/plugins/plugin-page/nav-operations'
import { MARKETPLACE_CONTAINER_ID } from '../constants'
import PluginTypeSwitch from '../plugin-type-switch'
import SearchBoxWrapper from '../search-box/search-box-wrapper'
@ -27,7 +28,7 @@ const EXPANDED_TABS_MARGIN_TOP = 32
const Description = ({
isMarketplacePlatform = false,
marketplaceNav,
scrollContainerId = 'marketplace-container',
scrollContainerId = MARKETPLACE_CONTAINER_ID,
}: DescriptionProps) => {
const { t } = useTranslation('plugin')
const { t: tCommon } = useTranslation('common')

View File

@ -50,11 +50,9 @@ describe('HomeCatalogNavigation', () => {
expect(activeTab).toHaveClass('bg-state-base-active')
expect(activeTab).not.toHaveClass('text-text-accent')
expect(activeTab.querySelector('[aria-hidden="true"]')).not.toBeInTheDocument()
expect(screen.queryByText('plugin.marketplace.home.new')).not.toBeInTheDocument()
expect(
screen.getByRole('link', { name: /plugin\.marketplace\.home\.templates/ }),
).toHaveAttribute('href', '/templates')
expect(screen.queryByText('plugin.marketplace.home.new')).not.toBeInTheDocument()
expect(screen.getByTestId('plugin-type-switch')).toHaveAttribute('data-variant', 'home')
})
@ -71,7 +69,6 @@ describe('HomeCatalogNavigation', () => {
expect(templatesTab).toHaveAttribute('href', '/templates')
expect(templatesTab).toHaveClass('cursor-pointer')
expect(templatesTab).not.toHaveClass('bg-state-base-active')
expect(screen.queryByText('plugin.marketplace.home.new')).not.toBeInTheDocument()
})
it('marks Templates as active when rendering the Templates catalog', () => {
@ -87,7 +84,6 @@ describe('HomeCatalogNavigation', () => {
expect(templatesTab).toHaveClass('bg-state-base-active')
expect(templatesTab).not.toHaveClass('text-text-accent')
expect(templatesTab.querySelector('[aria-hidden="true"]')).not.toBeInTheDocument()
expect(screen.queryByText('plugin.marketplace.home.new')).not.toBeInTheDocument()
})
it('uses request-localized labels and preserves the selected language', () => {

View File

@ -133,6 +133,5 @@ describe('HomeHeader', () => {
expect(templatesTab).toHaveClass('bg-state-base-active')
expect(templatesTab).not.toHaveClass('text-text-accent')
expect(templatesTab.querySelector('[aria-hidden="true"]')).not.toBeInTheDocument()
expect(screen.queryByText('marketplace.home.new')).not.toBeInTheDocument()
})
})

View File

@ -21,6 +21,11 @@ vi.mock('@/app/components/plugins/base/badges/verified', () => ({
default: () => <span data-testid="verified-badge" />,
}))
vi.mock('@/config', async (importOriginal) => ({
...(await importOriginal<typeof import('@/config')>()),
MARKETPLACE_URL_PREFIX: 'https://marketplace.example.com',
}))
const banners: PluginBanner[] = [
{
id: 'recommend',
@ -410,6 +415,60 @@ describe('HomeTrending', () => {
})
})
it('sends embedded cards without a delivery link to the marketplace site', () => {
const bannerWithMixedLinks: PluginBanner = {
id: 'recommend-mixed',
style_type: 'recommend',
title: 'Trending',
sort: 0,
language: 'en',
content: {
theme_type: 'hottest',
cards: [
{
item_type: 'plugin',
item_id: 'langgenius/dropbox',
display_name: 'Dropbox',
link: 'https://external.example.com/dropbox',
card_position: 0,
},
{
// The console has no local /plugin route, so a card without a
// delivery-provided link must open the marketplace detail page.
item_type: 'plugin',
item_id: 'langgenius/notion',
display_name: 'Notion',
link: '',
card_position: 1,
},
{
item_type: 'template',
item_id: 'tpl-1',
display_name: 'Support Bot',
link: '',
card_position: 2,
},
],
},
}
render(<HomeTrending banners={[bannerWithMixedLinks]} isMarketplacePlatform={false} />)
expect(screen.getByRole('link', { name: 'Dropbox' })).toHaveAttribute(
'href',
'https://external.example.com/dropbox',
)
const marketplaceFallbackLink = screen.getByRole('link', { name: 'Notion' })
expect(marketplaceFallbackLink.getAttribute('href')).toMatch(
/^https:\/\/marketplace\.example\.com\/plugins\/langgenius\/notion/,
)
expect(marketplaceFallbackLink).toHaveAttribute('target', '_blank')
expect(screen.getByRole('link', { name: 'Support Bot' })).toHaveAttribute(
'href',
'/templates?tid=tpl-1',
)
})
it('renders no carousel when the API returns no banners', () => {
render(<HomeTrending banners={[]} isMarketplacePlatform />)

View File

@ -5,7 +5,9 @@ import { cn } from '@langgenius/dify-ui/cn'
import { useAtomValue, useSetAtom } from 'jotai'
import { useEffect, useRef } from 'react'
import { useTranslation } from '#i18n'
import { MARKETPLACE_CONTAINER_ID } from '../constants'
import PluginTypeSwitch from '../plugin-type-switch'
import { HOME_HEADER_HEIGHT_PX } from './home-constants'
import { homeCatalogPinnedAtom } from './home-sticky-state'
import styles from './home-sticky.module.css'
@ -14,8 +16,6 @@ type HomeCatalogNavigationProps = {
catalogTabs: ReactNode
}
const STICKY_TOP = 48
function HomeCatalogNavigation({ catalogCategories, catalogTabs }: HomeCatalogNavigationProps) {
const { t } = useTranslation()
const isPinned = useAtomValue(homeCatalogPinnedAtom)
@ -23,7 +23,7 @@ function HomeCatalogNavigation({ catalogCategories, catalogTabs }: HomeCatalogNa
const pinTriggerRef = useRef<HTMLSpanElement>(null)
useEffect(() => {
const scrollContainer = document.getElementById('marketplace-container')
const scrollContainer = document.getElementById(MARKETPLACE_CONTAINER_ID)
if (!scrollContainer) return
const previousOverflowAnchor = scrollContainer.style.overflowAnchor
@ -37,7 +37,7 @@ function HomeCatalogNavigation({ catalogCategories, catalogTabs }: HomeCatalogNa
const containerTop = scrollContainer.getBoundingClientRect().top
const triggerTop = pinTrigger.getBoundingClientRect().top
setIsPinned(triggerTop <= containerTop + STICKY_TOP)
setIsPinned(triggerTop <= containerTop + HOME_HEADER_HEIGHT_PX)
}
updatePinnedState()
@ -61,6 +61,8 @@ function HomeCatalogNavigation({ catalogCategories, catalogTabs }: HomeCatalogNa
styles.catalogNavigation,
isPinned && styles.catalogNavigationPinned,
)}
// Pins directly below the header, so the offset is the header height.
style={{ top: HOME_HEADER_HEIGHT_PX }}
>
<div className="w-full">
<div className={cn('-ml-2', isPinned && styles.catalogTabsPinned)}>{catalogTabs}</div>

View File

@ -0,0 +1,6 @@
/**
* Height of the marketplace home header in pixels. The sticky catalog
* navigation pins itself directly below the header, so the header height and
* the sticky offset must stay in sync; both read this constant.
*/
export const HOME_HEADER_HEIGHT_PX = 48

View File

@ -4,6 +4,7 @@ import Link from '@/next/link'
import MarketplaceLogoDark from '@/public/marketplace/dify-marketplace-logo-dark.svg'
import MarketplaceLogo from '@/public/marketplace/dify-marketplace-logo.svg'
import HomeCatalogTabs from './home-catalog-tabs'
import { HOME_HEADER_HEIGHT_PX } from './home-constants'
// HomeCreatorCenter stays in its own client module: it derives styles via
// buttonVariants(), which cannot be invoked inside this server component.
import HomeCreatorCenter from './home-creator-center'
@ -28,10 +29,8 @@ const HomeHeader = ({
}: HomeHeaderProps) => {
return (
<header
className={cn(
'sticky top-0 z-50 flex w-full shrink-0 items-center gap-4 bg-background-default px-4 py-1.5 md:px-9',
styles.header,
)}
className="sticky top-0 z-50 flex w-full shrink-0 items-center gap-4 bg-background-default px-4 py-1.5 md:px-9"
style={{ height: HOME_HEADER_HEIGHT_PX }}
>
<div className="flex min-w-0 flex-1 items-center gap-4">
<Link

View File

@ -1,6 +1,6 @@
.header {
height: 48px;
}
/* The header height and the sticky top offset of the catalog navigation are
inline styles driven by HOME_HEADER_HEIGHT_PX in home-constants.ts, so the
two values cannot drift apart. */
.headerCatalogTabs {
display: flex;
@ -42,7 +42,6 @@
.catalogNavigation {
position: sticky;
top: 48px;
z-index: 40;
padding: 24px 36px 16px;
}

View File

@ -0,0 +1,260 @@
'use client'
import type { PluginBanner } from '@dify/contracts/marketplace'
import type { RefObject } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from '#i18n'
import { MARKETPLACE_CONTAINER_ID } from '../constants'
import styles from './home-trending.module.css'
const AUTOPLAY_DELAY = 5000
const PAGINATION_DOT_SIZE = 6
const PAGINATION_ACTIVE_WIDTH = 40
const PAGINATION_GAP = 8
const PAGINATION_STEP = PAGINATION_DOT_SIZE + PAGINATION_GAP
const PAGINATION_ACTIVE_SHIFT = PAGINATION_ACTIVE_WIDTH - PAGINATION_DOT_SIZE
const getPaginationItemOffset = (index: number, selectedIndex: number) =>
index * PAGINATION_STEP + (index > selectedIndex ? PAGINATION_ACTIVE_SHIFT : 0)
type AutoplayPauseReason = 'focus' | 'hover' | 'reduced-motion' | 'user' | 'viewport' | 'visibility'
function TrendingNavigation({
banners,
selectedIndex,
carouselRootRef,
pauseWhenOffscreen,
onSelect,
onNext,
onPausedChange,
}: {
banners: PluginBanner[]
selectedIndex: number
carouselRootRef: RefObject<HTMLDivElement | null>
pauseWhenOffscreen: boolean
onSelect: (index: number) => void
onNext: () => void
onPausedChange?: (paused: boolean) => void
}) {
const { t } = useTranslation('plugin')
const progressRef = useRef<HTMLSpanElement>(null)
const progressAnimationRef = useRef<Animation | null>(null)
const pauseReasonsRef = useRef(
new Set<AutoplayPauseReason>(pauseWhenOffscreen ? ['viewport'] : []),
)
const [isUserPaused, setIsUserPaused] = useState(false)
const [isReducedMotionPaused, setIsReducedMotionPaused] = useState(false)
const isExplicitlyPaused = isUserPaused || isReducedMotionPaused
const paginationWidth =
PAGINATION_ACTIVE_WIDTH + Math.max(0, banners.length - 1) * PAGINATION_STEP
const setPauseReason = useCallback(
(reason: AutoplayPauseReason, shouldPause: boolean) => {
if (shouldPause) pauseReasonsRef.current.add(reason)
else pauseReasonsRef.current.delete(reason)
const isPaused = pauseReasonsRef.current.size > 0
onPausedChange?.(isPaused)
const progressAnimation = progressAnimationRef.current
if (!progressAnimation) return
if (isPaused) progressAnimation.pause()
else progressAnimation.play()
},
[onPausedChange],
)
useEffect(() => {
const progressElement = progressRef.current
if (!progressElement?.animate) return
const progressAnimation = progressElement.animate(
[{ transform: 'scaleX(0)' }, { transform: 'scaleX(1)' }],
{
duration: AUTOPLAY_DELAY,
easing: 'linear',
fill: 'forwards',
},
)
progressAnimationRef.current = progressAnimation
if (pauseReasonsRef.current.size > 0) progressAnimation.pause()
progressAnimation.onfinish = onNext
return () => {
progressAnimation.onfinish = null
progressAnimation.cancel()
if (progressAnimationRef.current === progressAnimation) progressAnimationRef.current = null
}
}, [onNext, selectedIndex])
useEffect(() => {
const carouselRoot = carouselRootRef.current
if (!carouselRoot) return
const handleMouseEnter = () => setPauseReason('hover', true)
const handleMouseLeave = () => setPauseReason('hover', false)
const handleFocusIn = () => setPauseReason('focus', true)
const handleFocusOut = (event: FocusEvent) => {
if (carouselRoot.contains(event.relatedTarget as Node | null)) return
setPauseReason('focus', false)
}
const handleVisibilityChange = () =>
setPauseReason('visibility', document.visibilityState === 'hidden')
carouselRoot.addEventListener('mouseenter', handleMouseEnter)
carouselRoot.addEventListener('mouseleave', handleMouseLeave)
carouselRoot.addEventListener('focusin', handleFocusIn)
carouselRoot.addEventListener('focusout', handleFocusOut)
document.addEventListener('visibilitychange', handleVisibilityChange)
handleVisibilityChange()
return () => {
carouselRoot.removeEventListener('mouseenter', handleMouseEnter)
carouselRoot.removeEventListener('mouseleave', handleMouseLeave)
carouselRoot.removeEventListener('focusin', handleFocusIn)
carouselRoot.removeEventListener('focusout', handleFocusOut)
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
}, [carouselRootRef, setPauseReason])
useEffect(() => {
if (!pauseWhenOffscreen) {
setPauseReason('viewport', false)
return
}
const carouselRoot = carouselRootRef.current
if (!carouselRoot) return
if (typeof IntersectionObserver === 'undefined') {
setPauseReason('viewport', false)
return
}
const observer = new IntersectionObserver(
([entry]) => {
const isVisible = !!entry?.isIntersecting && entry.intersectionRatio >= 0.25
setPauseReason('viewport', !isVisible)
},
{
root: document.getElementById(MARKETPLACE_CONTAINER_ID),
threshold: 0.25,
},
)
observer.observe(carouselRoot)
return () => observer.disconnect()
}, [carouselRootRef, pauseWhenOffscreen, setPauseReason])
useEffect(() => {
const reducedMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
const syncReducedMotion = () => {
// oxlint-disable-next-line eslint-react/set-state-in-effect -- This state mirrors an external media query.
setIsReducedMotionPaused(reducedMotionQuery.matches)
setPauseReason('reduced-motion', reducedMotionQuery.matches)
}
syncReducedMotion()
reducedMotionQuery.addEventListener('change', syncReducedMotion)
return () => reducedMotionQuery.removeEventListener('change', syncReducedMotion)
}, [setPauseReason])
const toggleAutoplay = () => {
if (isExplicitlyPaused) {
setIsUserPaused(false)
setIsReducedMotionPaused(false)
setPauseReason('user', false)
setPauseReason('reduced-motion', false)
// Activating Play keeps the pointer and/or keyboard focus on the button
// itself, so the implicit hover/focus reasons would silently keep the
// rotation paused. An explicit Play overrides them; they re-engage on
// the next mouseenter/focusin.
setPauseReason('focus', false)
setPauseReason('hover', false)
return
}
setIsUserPaused(true)
setPauseReason('user', true)
}
return (
<div
role="group"
aria-label={t(($) => $['marketplace.home.trendingPaginationLabel'])}
className={cn(
styles.navigation,
'absolute right-0 z-10 flex h-[22px] items-center gap-2 px-5 py-2',
)}
>
<div className="relative h-1.5 shrink-0" style={{ width: paginationWidth }}>
<span
aria-hidden
className="pointer-events-none absolute top-0 left-0 z-1 flex h-1.5 w-10 items-center overflow-hidden rounded-full bg-state-base-handle transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform motion-reduce:transition-none"
style={{
transform: `translate3d(${selectedIndex * PAGINATION_STEP}px, 0, 0)`,
}}
>
<span
key={selectedIndex}
ref={progressRef}
data-carousel-progress
className="h-full w-full rounded-full bg-text-accent"
style={{ transform: 'scaleX(0)', transformOrigin: 'left center' }}
/>
</span>
{banners.map((banner, index) => {
const isCurrent = index === selectedIndex
return (
<button
key={banner.id}
type="button"
aria-label={banner.title}
aria-current={isCurrent ? 'true' : undefined}
onClick={() => {
if (isCurrent) return
onSelect(index)
}}
className={cn(
'absolute top-0 left-0 z-2 h-1.5 overflow-hidden rounded-full outline-hidden transition-[transform,width,background-color] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] after:absolute after:-inset-2 hover:bg-state-base-handle-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid motion-reduce:transition-none',
isCurrent ? 'bg-transparent' : 'bg-state-base-handle',
)}
style={{
width: isCurrent ? PAGINATION_ACTIVE_WIDTH : PAGINATION_DOT_SIZE,
transform: `translate3d(${getPaginationItemOffset(index, selectedIndex)}px, 0, 0)`,
}}
/>
)
})}
</div>
<div className="min-w-0 flex-1" />
<button
type="button"
aria-label={t(
($) =>
$[
isExplicitlyPaused
? 'marketplace.home.trendingPlay'
: 'marketplace.home.trendingPause'
],
)}
onClick={toggleAutoplay}
className="flex size-4 shrink-0 items-center justify-center rounded-full bg-state-base-handle text-text-primary outline-hidden hover:bg-state-base-handle-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
{isExplicitlyPaused ? (
<span aria-hidden className="i-ri-play-large-fill size-2 opacity-30" />
) : (
<span aria-hidden className="i-ri-pause-large-fill size-2 opacity-30" />
)}
</button>
</div>
)
}
export default TrendingNavigation

View File

@ -0,0 +1,331 @@
'use client'
import type {
BannerAd,
BannerBlog,
BannerEvent,
BannerRecommend,
BannerRecommendCard,
PluginBanner,
} from '@dify/contracts/marketplace'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from '#i18n'
import Partner from '@/app/components/plugins/base/badges/partner'
import Verified from '@/app/components/plugins/base/badges/verified'
import { MARKETPLACE_API_PREFIX } from '@/config'
import Link from '@/next/link'
import { getPluginLinkInMarketplace } from '../utils'
import background from './assets/background.webp'
import difyUpdatesArt from './assets/dify-updates-art.png'
import styles from './home-trending.module.css'
const getMarketplaceAssetURL = (path?: string) => {
if (!path) return ''
if (/^https?:\/\//.test(path) || path.startsWith('/_next/')) return path
try {
const apiURL = new URL(MARKETPLACE_API_PREFIX)
if (path.startsWith('/api/')) return `${apiURL.origin}${path}`
return `${MARKETPLACE_API_PREFIX.replace(/\/$/, '')}/${path.replace(/^\//, '')}`
} catch {
return path
}
}
const getLocalCardHref = (card: BannerRecommendCard) => {
if (card.item_type === 'plugin') {
const [organization, pluginName] = card.item_id.split('/')
if (organization && pluginName)
return `/plugin/${encodeURIComponent(organization)}/${encodeURIComponent(pluginName)}`
}
if (card.item_type === 'template') return `/templates?tid=${encodeURIComponent(card.item_id)}`
return '/'
}
const getCardHref = (card: BannerRecommendCard, isMarketplacePlatform: boolean) => {
if (isMarketplacePlatform) return getLocalCardHref(card)
if (card.link) return card.link
// The embedded console has no local plugin detail route, so a plugin card
// without a delivery-provided link opens the marketplace site detail page.
if (card.item_type === 'plugin') {
const [organization, pluginName] = card.item_id.split('/')
if (organization && pluginName)
return getPluginLinkInMarketplace({ org: organization, name: pluginName, type: 'plugin' })
}
return getLocalCardHref(card)
}
const getCardCreator = (card: BannerRecommendCard) => {
if (card.creator) return card.creator
if (card.item_type !== 'plugin') return ''
return card.item_id.split('/')[0] || ''
}
function TrendingCopy({
banner,
isMarketplacePlatform,
}: {
banner: BannerRecommend
isMarketplacePlatform: boolean
}) {
const { t } = useTranslation('plugin')
const heading = banner.content.heading || t(($) => $['marketplace.home.trendingTitle'])
const description =
banner.content.description ||
banner.content.subheadings?.join(' · ') ||
t(($) => $['marketplace.home.trendingDescription'])
return (
<div
className={cn(
styles.copy,
'flex min-w-0 flex-col items-start overflow-hidden p-5',
isMarketplacePlatform ? styles.marketplaceCopy : styles.embeddedCopy,
)}
>
<div className="flex w-full flex-col items-start gap-2 overflow-hidden">
<p className="shrink-0 rounded-sm bg-state-accent-hover-alt px-1.5 py-0.5 text-[10px] leading-3 font-semibold tracking-[-0.2px] text-text-accent">
{banner.title}
</p>
<h2 className="shrink-0 text-xl leading-6 font-semibold tracking-[-0.4px] text-text-primary">
{heading}
</h2>
<p className="w-full text-[13px] leading-5 font-normal tracking-[-0.065px] text-text-tertiary">
{description}
</p>
</div>
</div>
)
}
function TrendingCard({
card,
isMarketplacePlatform,
}: {
card: BannerRecommendCard
isMarketplacePlatform: boolean
}) {
const { t } = useTranslation('plugin')
const iconURL = getMarketplaceAssetURL(card.icon_url)
const creator = getCardCreator(card)
const href = getCardHref(card, isMarketplacePlatform)
const opensInNewTab = !isMarketplacePlatform && /^https?:\/\//.test(href)
const isPartner = card.badges?.includes('partner')
const isVerified = card.badges?.includes('verified')
return (
<Link
href={href}
target={opensInNewTab ? '_blank' : undefined}
rel={opensInNewTab ? 'noopener noreferrer' : undefined}
aria-label={card.display_name}
className={cn(
styles.card,
'flex h-[116px] shrink-0 flex-col items-start justify-between overflow-hidden rounded-lg bg-background-default-dodge p-3.5 outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid',
)}
>
<div
className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-[10px] border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge"
style={{
backgroundColor: !iconURL ? card.icon_background : undefined,
}}
>
{iconURL ? (
<img
src={iconURL}
width={40}
height={40}
alt=""
aria-hidden
className="size-full object-cover"
/>
) : card.icon ? (
<span className="text-xl leading-none">{card.icon}</span>
) : (
<span aria-hidden="true" className="i-ri-image-line size-5 text-text-quaternary" />
)}
</div>
<div className="flex w-full items-end gap-1">
<div className="flex min-w-0 flex-1 flex-col items-start gap-[3px]">
<div className="flex w-full min-w-0 items-center gap-[3px]">
<h3 className="min-w-0 truncate text-sm leading-[normal] font-medium text-text-primary">
{card.display_name}
</h3>
{(isPartner || isVerified) && (
<div className="flex shrink-0 items-start gap-[3.5px]">
{isPartner && (
<Partner className="size-3.5" text={t(($) => $['marketplace.partnerTip'])} />
)}
{isVerified && (
<Verified className="size-3.5" text={t(($) => $['marketplace.verifiedTip'])} />
)}
</div>
)}
</div>
{creator && (
<p className="w-full truncate text-xs leading-[normal] font-normal text-text-tertiary">
{t(($) => $['marketplace.home.trendingByCreator'], { creator })}
</p>
)}
</div>
<span className="shrink-0 rounded-full bg-background-section-burn px-1.5 py-[3px] text-[10px] leading-3 font-normal text-text-primary">
{t(($) => $['marketplace.home.trendingView'])}
</span>
</div>
</Link>
)
}
function TrendingRecommendationSlide({
banner,
isMarketplacePlatform,
}: {
banner: BannerRecommend
isMarketplacePlatform: boolean
}) {
return (
<div
className={cn(
styles.recommendSlide,
'flex h-[200px] w-full overflow-hidden rounded-2xl bg-background-body',
)}
>
<TrendingCopy banner={banner} isMarketplacePlatform={isMarketplacePlatform} />
<div
className={cn(
styles.recommendVisual,
'relative h-[200px] shrink-0 overflow-hidden rounded-xl bg-background-body',
)}
>
<img
src={background.src}
width={1600}
height={900}
alt=""
aria-hidden
className="absolute top-[-173px] left-[-990px] h-[1201px] w-[2135px] max-w-none opacity-80"
/>
<div aria-hidden className="absolute inset-0 bg-text-accent mix-blend-color" />
<div className={cn(styles.recommendCards, 'relative z-10 h-full items-center')}>
{banner.content.cards.map((card) => (
<TrendingCard
key={`${card.item_type}:${card.item_id}`}
card={card}
isMarketplacePlatform={isMarketplacePlatform}
/>
))}
</div>
</div>
</div>
)
}
function BlogBannerSlide({ banner }: { banner: BannerBlog }) {
const { t } = useTranslation('plugin')
const opensInNewTab = /^https?:\/\//.test(banner.content.link)
return (
<Link
href={banner.content.link}
target={opensInNewTab ? '_blank' : undefined}
rel={opensInNewTab ? 'noopener noreferrer' : undefined}
aria-label={t(($) => $['marketplace.home.trendingReadMoreAbout'], {
title: banner.content.blog_title,
})}
className="flex h-[200px] w-full overflow-hidden rounded-2xl bg-background-body outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
<div className="flex min-w-0 flex-1 flex-col items-start overflow-hidden px-6 py-5">
<div className="flex min-h-0 w-full flex-1 flex-col items-start gap-2">
<p className="shrink-0 rounded-sm bg-state-success-hover-alt px-1.5 py-0.5 text-[10px] leading-3 font-semibold tracking-[-0.2px] text-text-success">
{banner.title}
</p>
<div className="flex min-h-0 w-full max-w-[800px] flex-1 flex-col items-start gap-3">
<h2 className="shrink-0 text-xl leading-6 font-semibold tracking-[-0.4px] text-text-primary">
{banner.content.blog_title}
</h2>
<div className="flex min-h-0 w-full flex-1 flex-col items-start gap-2">
{banner.content.subtitle && (
<p className="shrink-0 text-[15px] leading-[18px] font-normal tracking-[-0.3px] text-text-primary">
{banner.content.subtitle}
</p>
)}
{banner.content.description && (
<p className="min-h-0 w-full flex-1 overflow-hidden text-[13px] leading-5 font-normal tracking-[-0.065px] text-text-tertiary">
<span className={styles.updatesDescription}>{banner.content.description}</span>
</p>
)}
<span
aria-hidden
className="flex shrink-0 items-center gap-1 text-[13px] leading-[normal] font-medium text-text-accent underline decoration-[10%] underline-offset-2"
>
<span>{t(($) => $['marketplace.home.trendingReadMore'])}</span>
<span className="i-ri-arrow-right-s-line size-4" />
</span>
</div>
</div>
</div>
</div>
<img
src={difyUpdatesArt.src}
width={400}
height={200}
alt=""
aria-hidden
className={cn(styles.updatesArt, 'h-[200px] shrink-0 object-cover')}
/>
</Link>
)
}
function ImageBannerSlide({ banner }: { banner: BannerEvent | BannerAd }) {
const desktopImage = getMarketplaceAssetURL(banner.content.images.desktop)
const tabletImage = getMarketplaceAssetURL(banner.content.images.tablet)
const mobileImage = getMarketplaceAssetURL(banner.content.images.mobile)
return (
<Link
href={banner.content.link}
target="_blank"
rel="noopener noreferrer"
aria-label={banner.content.alt_text || banner.title}
className="block h-[200px] w-full overflow-hidden rounded-2xl outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
<picture className="block size-full">
{mobileImage && <source media="(max-width: 639px)" srcSet={mobileImage} />}
{tabletImage && <source media="(max-width: 1023px)" srcSet={tabletImage} />}
<img
src={desktopImage}
width={1200}
height={200}
alt=""
aria-hidden
className="size-full object-cover"
/>
</picture>
</Link>
)
}
export function HomeBannerSlide({
banner,
isMarketplacePlatform,
}: {
banner: PluginBanner
isMarketplacePlatform: boolean
}) {
if (banner.style_type === 'blog') return <BlogBannerSlide banner={banner} />
if (banner.style_type === 'event' || banner.style_type === 'ad')
return <ImageBannerSlide banner={banner} />
return (
<TrendingRecommendationSlide banner={banner} isMarketplacePlatform={isMarketplacePlatform} />
)
}

View File

@ -1,574 +1,12 @@
'use client'
import type {
BannerAd,
BannerBlog,
BannerEvent,
BannerRecommend,
BannerRecommendCard,
PluginBanner,
} from '@dify/contracts/marketplace'
import type { RefObject } from 'react'
import type { PluginBanner } from '@dify/contracts/marketplace'
import { cn } from '@langgenius/dify-ui/cn'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from '#i18n'
import Partner from '@/app/components/plugins/base/badges/partner'
import Verified from '@/app/components/plugins/base/badges/verified'
import { MARKETPLACE_API_PREFIX } from '@/config'
import Link from '@/next/link'
import background from './assets/background.webp'
import difyUpdatesArt from './assets/dify-updates-art.png'
import styles from './home-trending.module.css'
const AUTOPLAY_DELAY = 5000
const PAGINATION_DOT_SIZE = 6
const PAGINATION_ACTIVE_WIDTH = 40
const PAGINATION_GAP = 8
const PAGINATION_STEP = PAGINATION_DOT_SIZE + PAGINATION_GAP
const PAGINATION_ACTIVE_SHIFT = PAGINATION_ACTIVE_WIDTH - PAGINATION_DOT_SIZE
const getPaginationItemOffset = (index: number, selectedIndex: number) =>
index * PAGINATION_STEP + (index > selectedIndex ? PAGINATION_ACTIVE_SHIFT : 0)
type AutoplayPauseReason = 'focus' | 'hover' | 'reduced-motion' | 'user' | 'viewport' | 'visibility'
function TrendingCopy({
banner,
isMarketplacePlatform,
}: {
banner: BannerRecommend
isMarketplacePlatform: boolean
}) {
const { t } = useTranslation('plugin')
const heading = banner.content.heading || t(($) => $['marketplace.home.trendingTitle'])
const description =
banner.content.description ||
banner.content.subheadings?.join(' · ') ||
t(($) => $['marketplace.home.trendingDescription'])
return (
<div
className={cn(
styles.copy,
'flex min-w-0 flex-col items-start overflow-hidden p-5',
isMarketplacePlatform ? styles.marketplaceCopy : styles.embeddedCopy,
)}
>
<div className="flex w-full flex-col items-start gap-2 overflow-hidden">
<p className="shrink-0 rounded-sm bg-state-accent-hover-alt px-1.5 py-0.5 text-[10px] leading-3 font-semibold tracking-[-0.2px] text-text-accent">
{banner.title}
</p>
<h2 className="shrink-0 text-xl leading-6 font-semibold tracking-[-0.4px] text-text-primary">
{heading}
</h2>
<p className="w-full text-[13px] leading-5 font-normal tracking-[-0.065px] text-text-tertiary">
{description}
</p>
</div>
</div>
)
}
const getMarketplaceAssetURL = (path?: string) => {
if (!path) return ''
if (/^https?:\/\//.test(path) || path.startsWith('/_next/')) return path
try {
const apiURL = new URL(MARKETPLACE_API_PREFIX)
if (path.startsWith('/api/')) return `${apiURL.origin}${path}`
return `${MARKETPLACE_API_PREFIX.replace(/\/$/, '')}/${path.replace(/^\//, '')}`
} catch {
return path
}
}
const getLocalCardHref = (card: BannerRecommendCard) => {
if (card.item_type === 'plugin') {
const [organization, pluginName] = card.item_id.split('/')
if (organization && pluginName)
return `/plugin/${encodeURIComponent(organization)}/${encodeURIComponent(pluginName)}`
}
if (card.item_type === 'template') return `/templates?tid=${encodeURIComponent(card.item_id)}`
return '/'
}
const getCardHref = (card: BannerRecommendCard, isMarketplacePlatform: boolean) => {
if (!isMarketplacePlatform && card.link) return card.link
return getLocalCardHref(card)
}
const getCardCreator = (card: BannerRecommendCard) => {
if (card.creator) return card.creator
if (card.item_type !== 'plugin') return ''
return card.item_id.split('/')[0] || ''
}
function TrendingCard({
card,
isMarketplacePlatform,
}: {
card: BannerRecommendCard
isMarketplacePlatform: boolean
}) {
const { t } = useTranslation('plugin')
const iconURL = getMarketplaceAssetURL(card.icon_url)
const creator = getCardCreator(card)
const href = getCardHref(card, isMarketplacePlatform)
const opensInNewTab = !isMarketplacePlatform && /^https?:\/\//.test(href)
const isPartner = card.badges?.includes('partner')
const isVerified = card.badges?.includes('verified')
return (
<Link
href={href}
target={opensInNewTab ? '_blank' : undefined}
rel={opensInNewTab ? 'noopener noreferrer' : undefined}
aria-label={card.display_name}
className={cn(
styles.card,
'flex h-[116px] shrink-0 flex-col items-start justify-between overflow-hidden rounded-lg bg-background-default-dodge p-3.5 outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid',
)}
>
<div
className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-[10px] border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge"
style={{
backgroundColor: !iconURL ? card.icon_background : undefined,
}}
>
{iconURL ? (
<img
src={iconURL}
width={40}
height={40}
alt=""
aria-hidden
className="size-full object-cover"
/>
) : card.icon ? (
<span className="text-xl leading-none">{card.icon}</span>
) : (
<span aria-hidden="true" className="i-ri-image-line size-5 text-text-quaternary" />
)}
</div>
<div className="flex w-full items-end gap-1">
<div className="flex min-w-0 flex-1 flex-col items-start gap-[3px]">
<div className="flex w-full min-w-0 items-center gap-[3px]">
<h3 className="min-w-0 truncate text-sm leading-[normal] font-medium text-text-primary">
{card.display_name}
</h3>
{(isPartner || isVerified) && (
<div className="flex shrink-0 items-start gap-[3.5px]">
{isPartner && (
<Partner className="size-3.5" text={t(($) => $['marketplace.partnerTip'])} />
)}
{isVerified && (
<Verified className="size-3.5" text={t(($) => $['marketplace.verifiedTip'])} />
)}
</div>
)}
</div>
{creator && (
<p className="w-full truncate text-xs leading-[normal] font-normal text-text-tertiary">
{t(($) => $['marketplace.home.trendingByCreator'], { creator })}
</p>
)}
</div>
<span className="shrink-0 rounded-full bg-background-section-burn px-1.5 py-[3px] text-[10px] leading-3 font-normal text-text-primary">
{t(($) => $['marketplace.home.trendingView'])}
</span>
</div>
</Link>
)
}
function TrendingRecommendationSlide({
banner,
isMarketplacePlatform,
}: {
banner: BannerRecommend
isMarketplacePlatform: boolean
}) {
return (
<div
className={cn(
styles.recommendSlide,
'flex h-[200px] w-full overflow-hidden rounded-2xl bg-background-body',
)}
>
<TrendingCopy banner={banner} isMarketplacePlatform={isMarketplacePlatform} />
<div
className={cn(
styles.recommendVisual,
'relative h-[200px] shrink-0 overflow-hidden rounded-xl bg-background-body',
)}
>
<img
src={background.src}
width={1600}
height={900}
alt=""
aria-hidden
className="absolute top-[-173px] left-[-990px] h-[1201px] w-[2135px] max-w-none opacity-80"
/>
<div aria-hidden className="absolute inset-0 bg-text-accent mix-blend-color" />
<div className={cn(styles.recommendCards, 'relative z-10 h-full items-center')}>
{banner.content.cards.map((card) => (
<TrendingCard
key={`${card.item_type}:${card.item_id}`}
card={card}
isMarketplacePlatform={isMarketplacePlatform}
/>
))}
</div>
</div>
</div>
)
}
function BlogBannerSlide({ banner }: { banner: BannerBlog }) {
const { t } = useTranslation('plugin')
const opensInNewTab = /^https?:\/\//.test(banner.content.link)
return (
<Link
href={banner.content.link}
target={opensInNewTab ? '_blank' : undefined}
rel={opensInNewTab ? 'noopener noreferrer' : undefined}
aria-label={t(($) => $['marketplace.home.trendingReadMoreAbout'], {
title: banner.content.blog_title,
})}
className="flex h-[200px] w-full overflow-hidden rounded-2xl bg-background-body outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
<div className="flex min-w-0 flex-1 flex-col items-start overflow-hidden px-6 py-5">
<div className="flex min-h-0 w-full flex-1 flex-col items-start gap-2">
<p className="shrink-0 rounded-sm bg-state-success-hover-alt px-1.5 py-0.5 text-[10px] leading-3 font-semibold tracking-[-0.2px] text-text-success">
{banner.title}
</p>
<div className="flex min-h-0 w-full max-w-[800px] flex-1 flex-col items-start gap-3">
<h2 className="shrink-0 text-xl leading-6 font-semibold tracking-[-0.4px] text-text-primary">
{banner.content.blog_title}
</h2>
<div className="flex min-h-0 w-full flex-1 flex-col items-start gap-2">
{banner.content.subtitle && (
<p className="shrink-0 text-[15px] leading-[18px] font-normal tracking-[-0.3px] text-text-primary">
{banner.content.subtitle}
</p>
)}
{banner.content.description && (
<p className="min-h-0 w-full flex-1 overflow-hidden text-[13px] leading-5 font-normal tracking-[-0.065px] text-text-tertiary">
<span className={styles.updatesDescription}>{banner.content.description}</span>
</p>
)}
<span
aria-hidden
className="flex shrink-0 items-center gap-1 text-[13px] leading-[normal] font-medium text-text-accent underline decoration-[10%] underline-offset-2"
>
<span>{t(($) => $['marketplace.home.trendingReadMore'])}</span>
<span className="i-ri-arrow-right-s-line size-4" />
</span>
</div>
</div>
</div>
</div>
<img
src={difyUpdatesArt.src}
width={400}
height={200}
alt=""
aria-hidden
className={cn(styles.updatesArt, 'h-[200px] shrink-0 object-cover')}
/>
</Link>
)
}
function ImageBannerSlide({ banner }: { banner: BannerEvent | BannerAd }) {
const desktopImage = getMarketplaceAssetURL(banner.content.images.desktop)
const tabletImage = getMarketplaceAssetURL(banner.content.images.tablet)
const mobileImage = getMarketplaceAssetURL(banner.content.images.mobile)
return (
<Link
href={banner.content.link}
target="_blank"
rel="noopener noreferrer"
aria-label={banner.content.alt_text || banner.title}
className="block h-[200px] w-full overflow-hidden rounded-2xl outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
<picture className="block size-full">
{mobileImage && <source media="(max-width: 639px)" srcSet={mobileImage} />}
{tabletImage && <source media="(max-width: 1023px)" srcSet={tabletImage} />}
<img
src={desktopImage}
width={1200}
height={200}
alt=""
aria-hidden
className="size-full object-cover"
/>
</picture>
</Link>
)
}
function HomeBannerSlide({
banner,
isMarketplacePlatform,
}: {
banner: PluginBanner
isMarketplacePlatform: boolean
}) {
if (banner.style_type === 'blog') return <BlogBannerSlide banner={banner} />
if (banner.style_type === 'event' || banner.style_type === 'ad')
return <ImageBannerSlide banner={banner} />
return (
<TrendingRecommendationSlide banner={banner} isMarketplacePlatform={isMarketplacePlatform} />
)
}
function TrendingNavigation({
banners,
selectedIndex,
carouselRootRef,
pauseWhenOffscreen,
onSelect,
onNext,
onPausedChange,
}: {
banners: PluginBanner[]
selectedIndex: number
carouselRootRef: RefObject<HTMLDivElement | null>
pauseWhenOffscreen: boolean
onSelect: (index: number) => void
onNext: () => void
onPausedChange?: (paused: boolean) => void
}) {
const { t } = useTranslation('plugin')
const progressRef = useRef<HTMLSpanElement>(null)
const progressAnimationRef = useRef<Animation | null>(null)
const pauseReasonsRef = useRef(
new Set<AutoplayPauseReason>(pauseWhenOffscreen ? ['viewport'] : []),
)
const [isUserPaused, setIsUserPaused] = useState(false)
const [isReducedMotionPaused, setIsReducedMotionPaused] = useState(false)
const isExplicitlyPaused = isUserPaused || isReducedMotionPaused
const paginationWidth =
PAGINATION_ACTIVE_WIDTH + Math.max(0, banners.length - 1) * PAGINATION_STEP
const setPauseReason = useCallback(
(reason: AutoplayPauseReason, shouldPause: boolean) => {
if (shouldPause) pauseReasonsRef.current.add(reason)
else pauseReasonsRef.current.delete(reason)
const isPaused = pauseReasonsRef.current.size > 0
onPausedChange?.(isPaused)
const progressAnimation = progressAnimationRef.current
if (!progressAnimation) return
if (isPaused) progressAnimation.pause()
else progressAnimation.play()
},
[onPausedChange],
)
useEffect(() => {
const progressElement = progressRef.current
if (!progressElement?.animate) return
const progressAnimation = progressElement.animate(
[{ transform: 'scaleX(0)' }, { transform: 'scaleX(1)' }],
{
duration: AUTOPLAY_DELAY,
easing: 'linear',
fill: 'forwards',
},
)
progressAnimationRef.current = progressAnimation
if (pauseReasonsRef.current.size > 0) progressAnimation.pause()
progressAnimation.onfinish = onNext
return () => {
progressAnimation.onfinish = null
progressAnimation.cancel()
if (progressAnimationRef.current === progressAnimation) progressAnimationRef.current = null
}
}, [onNext, selectedIndex])
useEffect(() => {
const carouselRoot = carouselRootRef.current
if (!carouselRoot) return
const handleMouseEnter = () => setPauseReason('hover', true)
const handleMouseLeave = () => setPauseReason('hover', false)
const handleFocusIn = () => setPauseReason('focus', true)
const handleFocusOut = (event: FocusEvent) => {
if (carouselRoot.contains(event.relatedTarget as Node | null)) return
setPauseReason('focus', false)
}
const handleVisibilityChange = () =>
setPauseReason('visibility', document.visibilityState === 'hidden')
carouselRoot.addEventListener('mouseenter', handleMouseEnter)
carouselRoot.addEventListener('mouseleave', handleMouseLeave)
carouselRoot.addEventListener('focusin', handleFocusIn)
carouselRoot.addEventListener('focusout', handleFocusOut)
document.addEventListener('visibilitychange', handleVisibilityChange)
handleVisibilityChange()
return () => {
carouselRoot.removeEventListener('mouseenter', handleMouseEnter)
carouselRoot.removeEventListener('mouseleave', handleMouseLeave)
carouselRoot.removeEventListener('focusin', handleFocusIn)
carouselRoot.removeEventListener('focusout', handleFocusOut)
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
}, [carouselRootRef, setPauseReason])
useEffect(() => {
if (!pauseWhenOffscreen) {
setPauseReason('viewport', false)
return
}
const carouselRoot = carouselRootRef.current
if (!carouselRoot) return
if (typeof IntersectionObserver === 'undefined') {
setPauseReason('viewport', false)
return
}
const observer = new IntersectionObserver(
([entry]) => {
const isVisible = !!entry?.isIntersecting && entry.intersectionRatio >= 0.25
setPauseReason('viewport', !isVisible)
},
{
root: document.getElementById('marketplace-container'),
threshold: 0.25,
},
)
observer.observe(carouselRoot)
return () => observer.disconnect()
}, [carouselRootRef, pauseWhenOffscreen, setPauseReason])
useEffect(() => {
const reducedMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
const syncReducedMotion = () => {
// oxlint-disable-next-line eslint-react/set-state-in-effect -- This state mirrors an external media query.
setIsReducedMotionPaused(reducedMotionQuery.matches)
setPauseReason('reduced-motion', reducedMotionQuery.matches)
}
syncReducedMotion()
reducedMotionQuery.addEventListener('change', syncReducedMotion)
return () => reducedMotionQuery.removeEventListener('change', syncReducedMotion)
}, [setPauseReason])
const toggleAutoplay = () => {
if (isExplicitlyPaused) {
setIsUserPaused(false)
setIsReducedMotionPaused(false)
setPauseReason('user', false)
setPauseReason('reduced-motion', false)
// Activating Play keeps the pointer and/or keyboard focus on the button
// itself, so the implicit hover/focus reasons would silently keep the
// rotation paused. An explicit Play overrides them; they re-engage on
// the next mouseenter/focusin.
setPauseReason('focus', false)
setPauseReason('hover', false)
return
}
setIsUserPaused(true)
setPauseReason('user', true)
}
return (
<div
role="group"
aria-label={t(($) => $['marketplace.home.trendingPaginationLabel'])}
className={cn(
styles.navigation,
'absolute right-0 z-10 flex h-[22px] items-center gap-2 px-5 py-2',
)}
>
<div className="relative h-1.5 shrink-0" style={{ width: paginationWidth }}>
<span
aria-hidden
className="pointer-events-none absolute top-0 left-0 z-1 flex h-1.5 w-10 items-center overflow-hidden rounded-full bg-state-base-handle transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform motion-reduce:transition-none"
style={{
transform: `translate3d(${selectedIndex * PAGINATION_STEP}px, 0, 0)`,
}}
>
<span
key={selectedIndex}
ref={progressRef}
data-carousel-progress
className="h-full w-full rounded-full bg-text-accent"
style={{ transform: 'scaleX(0)', transformOrigin: 'left center' }}
/>
</span>
{banners.map((banner, index) => {
const isCurrent = index === selectedIndex
return (
<button
key={banner.id}
type="button"
aria-label={banner.title}
aria-current={isCurrent ? 'true' : undefined}
onClick={() => {
if (isCurrent) return
onSelect(index)
}}
className={cn(
'absolute top-0 left-0 z-2 h-1.5 overflow-hidden rounded-full outline-hidden transition-[transform,width,background-color] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] after:absolute after:-inset-2 hover:bg-state-base-handle-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid motion-reduce:transition-none',
isCurrent ? 'bg-transparent' : 'bg-state-base-handle',
)}
style={{
width: isCurrent ? PAGINATION_ACTIVE_WIDTH : PAGINATION_DOT_SIZE,
transform: `translate3d(${getPaginationItemOffset(index, selectedIndex)}px, 0, 0)`,
}}
/>
)
})}
</div>
<div className="min-w-0 flex-1" />
<button
type="button"
aria-label={t(
($) =>
$[
isExplicitlyPaused
? 'marketplace.home.trendingPlay'
: 'marketplace.home.trendingPause'
],
)}
onClick={toggleAutoplay}
className="flex size-4 shrink-0 items-center justify-center rounded-full bg-state-base-handle text-text-primary outline-hidden hover:bg-state-base-handle-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
{isExplicitlyPaused ? (
<span aria-hidden className="i-ri-play-large-fill size-2 opacity-30" />
) : (
<span aria-hidden className="i-ri-pause-large-fill size-2 opacity-30" />
)}
</button>
</div>
)
}
import TrendingNavigation from './home-trending-navigation'
import { HomeBannerSlide } from './home-trending-slides'
function HomeTrending({
banners,

View File

@ -207,7 +207,7 @@ export const useMarketplacePlugins = (enabled = true) => {
export const useMarketplaceContainerScroll = (
callback: () => void,
scrollContainerId = 'marketplace-container',
scrollContainerId = MARKETPLACE_CONTAINER_ID,
) => {
const handleScroll = useCallback(
(e: Event) => {

View File

@ -7,6 +7,7 @@ import Autoplay from 'embla-carousel-autoplay'
import useEmblaCarousel from 'embla-carousel-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from '#i18n'
import { MARKETPLACE_CONTAINER_ID } from '../constants'
import { CAROUSEL_PAGE_CLASS } from './collection-constants'
export type CarouselPage = {
@ -314,7 +315,7 @@ const Carousel = ({
syncAutoplay()
},
{
root: document.getElementById('marketplace-container'),
root: document.getElementById(MARKETPLACE_CONTAINER_ID),
threshold: 0.25,
},
)

View File

@ -1,5 +1,11 @@
export const GRID_CLASS = 'grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'
export const BECOME_PARTNER_URL = 'https://share-na2.hsforms.com/1NiS4r9lsSqGcuNBB77DeEQ40s9fk'
// Collections whose header shows the "Become a Partner" call to action, as
// named by the Marketplace API for the plugin and template catalogs.
export const PARTNER_COLLECTION_NAMES = new Set(['partners', 'partner-template', 'Partner Template'])
export const CAROUSEL_PAGE_CLASS = 'w-full shrink-0'
export const CAROUSEL_PAGE_SIZE = {

View File

@ -7,14 +7,13 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { useLocale, useTranslation } from '#i18n'
import { getLanguage } from '@/i18n-config/language'
import { useMarketplaceMoreClick } from '../atoms'
import { MARKETPLACE_CONTAINER_ID } from '../constants'
import { buildCarouselPages } from '../utils'
import CardWrapper from './card-wrapper'
import Carousel from './carousel'
import { GRID_CLASS } from './collection-constants'
import { BECOME_PARTNER_URL, GRID_CLASS, PARTNER_COLLECTION_NAMES } from './collection-constants'
import { useCarouselItemsPerPage } from './use-carousel-items-per-page'
const BECOME_PARTNER_URL = 'https://share-na2.hsforms.com/1NiS4r9lsSqGcuNBB77DeEQ40s9fk'
const PARTNERS_COLLECTION_NAMES = new Set(['partners', 'partner-template', 'Partner Template'])
const COLLECTION_PRELOAD_MARGIN = '320px 0px'
const COLLECTION_INTERSECTION_THRESHOLD = 0.01
const MAX_PLACEHOLDER_CARDS = 8
@ -110,7 +109,7 @@ const CollectionSection = ({
const [isMounted, setIsMounted] = useState(!deferMount)
const pages = useMemo(() => buildCarouselPages(plugins, itemsPerPage), [itemsPerPage, plugins])
const hasMultiplePages = pages.length > 1
const isPartnersCollection = PARTNERS_COLLECTION_NAMES.has(collection.name)
const isPartnersCollection = PARTNER_COLLECTION_NAMES.has(collection.name)
useEffect(() => {
if (!deferMount || isMounted) return
@ -132,7 +131,7 @@ const CollectionSection = ({
observer.disconnect()
},
{
root: document.getElementById('marketplace-container'),
root: document.getElementById(MARKETPLACE_CONTAINER_ID),
rootMargin: COLLECTION_PRELOAD_MARGIN,
threshold: COLLECTION_INTERSECTION_THRESHOLD,
},

View File

@ -234,13 +234,11 @@
"marketplace.home.guide": "دليل",
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
"marketplace.home.heroTitle": "اكتشف. وسّع. ابنِ",
"marketplace.home.new": "جديد",
"marketplace.home.plugins": "المكونات الإضافية",
"marketplace.home.searchPlaceholder": "Search plugins or templates",
"marketplace.home.templates": "قوالب",
"marketplace.home.trendingByCreator": "by {{creator}}",
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
"marketplace.home.trendingEyebrow": "Trending Now",
"marketplace.home.trendingPaginationLabel": "Trending pages",
"marketplace.home.trendingPause": "إيقاف مؤقت",
"marketplace.home.trendingPlay": "تشغيل",

View File

@ -234,13 +234,11 @@
"marketplace.home.guide": "Leitfaden",
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
"marketplace.home.heroTitle": "Entdecken. Erweitern. Entwickeln",
"marketplace.home.new": "Neu",
"marketplace.home.plugins": "Plugins",
"marketplace.home.searchPlaceholder": "Search plugins or templates",
"marketplace.home.templates": "Vorlagen",
"marketplace.home.trendingByCreator": "by {{creator}}",
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
"marketplace.home.trendingEyebrow": "Trending Now",
"marketplace.home.trendingPaginationLabel": "Trending pages",
"marketplace.home.trendingPause": "Pausieren",
"marketplace.home.trendingPlay": "Abspielen",

View File

@ -234,13 +234,11 @@
"marketplace.home.guide": "Guide",
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
"marketplace.home.heroTitle": "Discover. Extend. Build",
"marketplace.home.new": "New",
"marketplace.home.plugins": "Plugins",
"marketplace.home.searchPlaceholder": "Search plugins or templates",
"marketplace.home.templates": "Templates",
"marketplace.home.trendingByCreator": "by {{creator}}",
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
"marketplace.home.trendingEyebrow": "Trending Now",
"marketplace.home.trendingPaginationLabel": "Trending pages",
"marketplace.home.trendingPause": "Pause",
"marketplace.home.trendingPlay": "Play",

View File

@ -234,13 +234,11 @@
"marketplace.home.guide": "Guía",
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
"marketplace.home.heroTitle": "Descubre. Amplía. Crea",
"marketplace.home.new": "Nuevo",
"marketplace.home.plugins": "Plugins",
"marketplace.home.searchPlaceholder": "Search plugins or templates",
"marketplace.home.templates": "Plantillas",
"marketplace.home.trendingByCreator": "by {{creator}}",
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
"marketplace.home.trendingEyebrow": "Trending Now",
"marketplace.home.trendingPaginationLabel": "Trending pages",
"marketplace.home.trendingPause": "Pausar",
"marketplace.home.trendingPlay": "Reproducir",

View File

@ -234,13 +234,11 @@
"marketplace.home.guide": "راهنما",
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
"marketplace.home.heroTitle": "کشف کنید. گسترش دهید. بسازید",
"marketplace.home.new": "جدید",
"marketplace.home.plugins": "افزونه‌ها",
"marketplace.home.searchPlaceholder": "Search plugins or templates",
"marketplace.home.templates": "الگوها",
"marketplace.home.trendingByCreator": "by {{creator}}",
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
"marketplace.home.trendingEyebrow": "Trending Now",
"marketplace.home.trendingPaginationLabel": "Trending pages",
"marketplace.home.trendingPause": "توقف",
"marketplace.home.trendingPlay": "پخش",

View File

@ -234,13 +234,11 @@
"marketplace.home.guide": "Guide",
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
"marketplace.home.heroTitle": "Découvrez. Étendez. Créez",
"marketplace.home.new": "Nouveau",
"marketplace.home.plugins": "Plugins",
"marketplace.home.searchPlaceholder": "Search plugins or templates",
"marketplace.home.templates": "Modèles",
"marketplace.home.trendingByCreator": "by {{creator}}",
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
"marketplace.home.trendingEyebrow": "Trending Now",
"marketplace.home.trendingPaginationLabel": "Trending pages",
"marketplace.home.trendingPause": "Mettre en pause",
"marketplace.home.trendingPlay": "Lire",

View File

@ -234,13 +234,11 @@
"marketplace.home.guide": "मार्गदर्शिका",
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
"marketplace.home.heroTitle": "खोजें। विस्तार करें। बनाएँ",
"marketplace.home.new": "नया",
"marketplace.home.plugins": "एकीकरण",
"marketplace.home.searchPlaceholder": "Search plugins or templates",
"marketplace.home.templates": "टेम्पलेट",
"marketplace.home.trendingByCreator": "by {{creator}}",
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
"marketplace.home.trendingEyebrow": "Trending Now",
"marketplace.home.trendingPaginationLabel": "Trending pages",
"marketplace.home.trendingPause": "रोकें",
"marketplace.home.trendingPlay": "चलाएं",

View File

@ -234,13 +234,11 @@
"marketplace.home.guide": "Panduan",
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
"marketplace.home.heroTitle": "Temukan. Perluas. Bangun",
"marketplace.home.new": "Baru",
"marketplace.home.plugins": "Plugin",
"marketplace.home.searchPlaceholder": "Search plugins or templates",
"marketplace.home.templates": "Templat",
"marketplace.home.trendingByCreator": "by {{creator}}",
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
"marketplace.home.trendingEyebrow": "Trending Now",
"marketplace.home.trendingPaginationLabel": "Trending pages",
"marketplace.home.trendingPause": "Jeda",
"marketplace.home.trendingPlay": "Putar",

View File

@ -234,13 +234,11 @@
"marketplace.home.guide": "Guida",
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
"marketplace.home.heroTitle": "Scopri. Estendi. Crea",
"marketplace.home.new": "Nuovo",
"marketplace.home.plugins": "Plugin",
"marketplace.home.searchPlaceholder": "Search plugins or templates",
"marketplace.home.templates": "Modelli",
"marketplace.home.trendingByCreator": "by {{creator}}",
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
"marketplace.home.trendingEyebrow": "Trending Now",
"marketplace.home.trendingPaginationLabel": "Trending pages",
"marketplace.home.trendingPause": "Pausa",
"marketplace.home.trendingPlay": "Riproduci",

View File

@ -234,13 +234,11 @@
"marketplace.home.guide": "ガイド",
"marketplace.home.heroSubtitle": "Dify Marketplace で、より安全で信頼性の高いプラグインを見つけましょう。",
"marketplace.home.heroTitle": "見つける。拡張する。構築する",
"marketplace.home.new": "新着",
"marketplace.home.plugins": "プラグイン",
"marketplace.home.searchPlaceholder": "プラグインまたはテンプレートを検索",
"marketplace.home.templates": "テンプレート",
"marketplace.home.trendingByCreator": "{{creator}} 作成",
"marketplace.home.trendingDescription": "実際の利用状況に基づく人気プラグインを2週間ごとに更新。ワークスペースでの実行数によるランキングで、有料掲載や編集部による選定はありません。",
"marketplace.home.trendingEyebrow": "トレンド",
"marketplace.home.trendingPaginationLabel": "トレンドページ",
"marketplace.home.trendingPause": "一時停止",
"marketplace.home.trendingPlay": "再生",

View File

@ -234,13 +234,11 @@
"marketplace.home.guide": "가이드",
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
"marketplace.home.heroTitle": "발견하고, 확장하고, 구축하세요",
"marketplace.home.new": "신규",
"marketplace.home.plugins": "플러그인",
"marketplace.home.searchPlaceholder": "Search plugins or templates",
"marketplace.home.templates": "템플릿",
"marketplace.home.trendingByCreator": "by {{creator}}",
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
"marketplace.home.trendingEyebrow": "Trending Now",
"marketplace.home.trendingPaginationLabel": "Trending pages",
"marketplace.home.trendingPause": "일시정지",
"marketplace.home.trendingPlay": "재생",

View File

@ -231,6 +231,15 @@
"marketplace.discover": "ຄົ້ນຫາ",
"marketplace.empower": "ເສີມພະລັງການພັດທະນາ AI ຂອງທ່ານ",
"marketplace.home.creatorCenter": "ສູນຜູ້ສ້າງ",
"marketplace.home.guide": "ຄູ່ມື",
"marketplace.home.heroSubtitle": "ສ້າງດ້ວຍປລັກອິນທີ່ປອດໄພ ແລະ ເຊື່ອຖືໄດ້ຫຼາຍຂຶ້ນຈາກ Dify Marketplace.",
"marketplace.home.heroTitle": "ຄົ້ນຫາ. ຕໍ່ຍອດ. ສ້າງສັນ",
"marketplace.home.plugins": "ປລັກອິນ",
"marketplace.home.searchPlaceholder": "ຄົ້ນຫາປລັກອິນ ຫຼື ແມ່ແບບ",
"marketplace.home.templates": "ແມ່ແບບ",
"marketplace.home.trendingByCreator": "ໂດຍ {{creator}}",
"marketplace.home.trendingDescription": "ຄັດເລືອກຈາກການນຳໃຊ້ຕົວຈິງ, ອັບເດດທຸກໆສອງອາທິດ. ຈັດອັນດັບຕາມການເອີ້ນໃຊ້ຕົວຈິງໃນທົ່ວທຸກ workspace — ບໍ່ມີການຈ່າຍເງິນເພື່ອໂຄສະນາ ຫຼື ການຄັດເລືອກໂດຍທີມງານ.",
"marketplace.home.trendingPaginationLabel": "ໜ້າກຳລັງນິຍົມ",
"marketplace.home.trendingPause": "ຢຸດຊົ່ວຄາວ",
"marketplace.home.trendingPlay": "ຫຼິ້ນ",
"marketplace.home.trendingReadMore": "ອ່ານເພີ່ມເຕີມ",

View File

@ -234,13 +234,11 @@
"marketplace.home.guide": "Handleiding",
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
"marketplace.home.heroTitle": "Ontdek. Breid uit. Bouw",
"marketplace.home.new": "Nieuw",
"marketplace.home.plugins": "Plugins",
"marketplace.home.searchPlaceholder": "Search plugins or templates",
"marketplace.home.templates": "Sjablonen",
"marketplace.home.trendingByCreator": "by {{creator}}",
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
"marketplace.home.trendingEyebrow": "Trending Now",
"marketplace.home.trendingPaginationLabel": "Trending pages",
"marketplace.home.trendingPause": "Pauzeren",
"marketplace.home.trendingPlay": "Afspelen",

View File

@ -234,13 +234,11 @@
"marketplace.home.guide": "Przewodnik",
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
"marketplace.home.heroTitle": "Odkrywaj. Rozszerzaj. Twórz",
"marketplace.home.new": "Nowość",
"marketplace.home.plugins": "Integracje",
"marketplace.home.searchPlaceholder": "Search plugins or templates",
"marketplace.home.templates": "Szablony",
"marketplace.home.trendingByCreator": "by {{creator}}",
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
"marketplace.home.trendingEyebrow": "Trending Now",
"marketplace.home.trendingPaginationLabel": "Trending pages",
"marketplace.home.trendingPause": "Wstrzymaj",
"marketplace.home.trendingPlay": "Odtwórz",

View File

@ -234,13 +234,11 @@
"marketplace.home.guide": "Guia",
"marketplace.home.heroSubtitle": "Crie com plugins mais seguros e confiáveis do Dify Marketplace.",
"marketplace.home.heroTitle": "Descubra. Expanda. Crie",
"marketplace.home.new": "Novo",
"marketplace.home.plugins": "Plugins",
"marketplace.home.searchPlaceholder": "Buscar plugins ou modelos",
"marketplace.home.templates": "Modelos",
"marketplace.home.trendingByCreator": "por {{creator}}",
"marketplace.home.trendingDescription": "Destaques por uso real, atualizados a cada duas semanas. Classificados pelas execuções reais nos espaços de trabalho — sem promoção paga ou seleção editorial.",
"marketplace.home.trendingEyebrow": "Em alta agora",
"marketplace.home.trendingPaginationLabel": "Páginas em alta",
"marketplace.home.trendingPause": "Pausar",
"marketplace.home.trendingPlay": "Reproduzir",

View File

@ -234,13 +234,11 @@
"marketplace.home.guide": "Ghid",
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
"marketplace.home.heroTitle": "Descoperă. Extinde. Construiește",
"marketplace.home.new": "Nou",
"marketplace.home.plugins": "Plugin-uri",
"marketplace.home.searchPlaceholder": "Search plugins or templates",
"marketplace.home.templates": "Șabloane",
"marketplace.home.trendingByCreator": "by {{creator}}",
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
"marketplace.home.trendingEyebrow": "Trending Now",
"marketplace.home.trendingPaginationLabel": "Trending pages",
"marketplace.home.trendingPause": "Pauză",
"marketplace.home.trendingPlay": "Redare",

View File

@ -234,13 +234,11 @@
"marketplace.home.guide": "Руководство",
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
"marketplace.home.heroTitle": "Открывайте. Расширяйте. Создавайте",
"marketplace.home.new": "Новое",
"marketplace.home.plugins": "Интеграции",
"marketplace.home.searchPlaceholder": "Search plugins or templates",
"marketplace.home.templates": "Шаблоны",
"marketplace.home.trendingByCreator": "by {{creator}}",
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
"marketplace.home.trendingEyebrow": "Trending Now",
"marketplace.home.trendingPaginationLabel": "Trending pages",
"marketplace.home.trendingPause": "Пауза",
"marketplace.home.trendingPlay": "Воспроизвести",

View File

@ -234,13 +234,11 @@
"marketplace.home.guide": "Vodnik",
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
"marketplace.home.heroTitle": "Odkrijte. Razširite. Ustvarite",
"marketplace.home.new": "Novo",
"marketplace.home.plugins": "Integracije",
"marketplace.home.searchPlaceholder": "Search plugins or templates",
"marketplace.home.templates": "Predloge",
"marketplace.home.trendingByCreator": "by {{creator}}",
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
"marketplace.home.trendingEyebrow": "Trending Now",
"marketplace.home.trendingPaginationLabel": "Trending pages",
"marketplace.home.trendingPause": "Premor",
"marketplace.home.trendingPlay": "Predvajaj",

View File

@ -234,13 +234,11 @@
"marketplace.home.guide": "คู่มือ",
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
"marketplace.home.heroTitle": "ค้นพบ ขยาย และสร้าง",
"marketplace.home.new": "ใหม่",
"marketplace.home.plugins": "ปลั๊กอิน",
"marketplace.home.searchPlaceholder": "Search plugins or templates",
"marketplace.home.templates": "เทมเพลต",
"marketplace.home.trendingByCreator": "by {{creator}}",
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
"marketplace.home.trendingEyebrow": "Trending Now",
"marketplace.home.trendingPaginationLabel": "Trending pages",
"marketplace.home.trendingPause": "หยุดชั่วคราว",
"marketplace.home.trendingPlay": "เล่น",

View File

@ -234,13 +234,11 @@
"marketplace.home.guide": "Kılavuz",
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
"marketplace.home.heroTitle": "Keşfet. Genişlet. Oluştur",
"marketplace.home.new": "Yeni",
"marketplace.home.plugins": "Eklentiler",
"marketplace.home.searchPlaceholder": "Search plugins or templates",
"marketplace.home.templates": "Şablonlar",
"marketplace.home.trendingByCreator": "by {{creator}}",
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
"marketplace.home.trendingEyebrow": "Trending Now",
"marketplace.home.trendingPaginationLabel": "Trending pages",
"marketplace.home.trendingPause": "Duraklat",
"marketplace.home.trendingPlay": "Oynat",

View File

@ -234,13 +234,11 @@
"marketplace.home.guide": "Посібник",
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
"marketplace.home.heroTitle": "Відкривайте. Розширюйте. Створюйте",
"marketplace.home.new": "Нове",
"marketplace.home.plugins": "Інтеграції",
"marketplace.home.searchPlaceholder": "Search plugins or templates",
"marketplace.home.templates": "Шаблони",
"marketplace.home.trendingByCreator": "by {{creator}}",
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
"marketplace.home.trendingEyebrow": "Trending Now",
"marketplace.home.trendingPaginationLabel": "Trending pages",
"marketplace.home.trendingPause": "Пауза",
"marketplace.home.trendingPlay": "Відтворити",

View File

@ -234,13 +234,11 @@
"marketplace.home.guide": "Hướng dẫn",
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
"marketplace.home.heroTitle": "Khám phá. Mở rộng. Xây dựng",
"marketplace.home.new": "Mới",
"marketplace.home.plugins": "Plugin",
"marketplace.home.searchPlaceholder": "Search plugins or templates",
"marketplace.home.templates": "Mẫu",
"marketplace.home.trendingByCreator": "by {{creator}}",
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
"marketplace.home.trendingEyebrow": "Trending Now",
"marketplace.home.trendingPaginationLabel": "Trending pages",
"marketplace.home.trendingPause": "Tạm dừng",
"marketplace.home.trendingPlay": "Phát",

View File

@ -234,13 +234,11 @@
"marketplace.home.guide": "指南",
"marketplace.home.heroSubtitle": "在 Dify Marketplace 探索更安全、更可靠的插件。",
"marketplace.home.heroTitle": "发现。扩展。构建",
"marketplace.home.new": "新",
"marketplace.home.plugins": "插件",
"marketplace.home.searchPlaceholder": "搜索插件或模板",
"marketplace.home.templates": "模板",
"marketplace.home.trendingByCreator": "由 {{creator}} 发布",
"marketplace.home.trendingDescription": "基于真实使用情况选出的热门插件,每两周更新一次。榜单按各工作区的实际运行次数排序,不含付费推广或编辑推荐。",
"marketplace.home.trendingEyebrow": "当前热门",
"marketplace.home.trendingPaginationLabel": "热门推荐页码",
"marketplace.home.trendingPause": "暂停",
"marketplace.home.trendingPlay": "播放",

View File

@ -234,13 +234,11 @@
"marketplace.home.guide": "指南",
"marketplace.home.heroSubtitle": "在 Dify Marketplace 探索更安全、更可靠的外掛程式。",
"marketplace.home.heroTitle": "探索。擴展。建構",
"marketplace.home.new": "新",
"marketplace.home.plugins": "外掛",
"marketplace.home.searchPlaceholder": "搜尋外掛程式或範本",
"marketplace.home.templates": "範本",
"marketplace.home.trendingByCreator": "由 {{creator}} 發布",
"marketplace.home.trendingDescription": "根據真實使用情況選出的熱門外掛程式,每兩週更新一次。榜單按各工作區的實際執行次數排序,不含付費推廣或編輯推薦。",
"marketplace.home.trendingEyebrow": "目前熱門",
"marketplace.home.trendingPaginationLabel": "熱門推薦頁碼",
"marketplace.home.trendingPause": "暫停",
"marketplace.home.trendingPlay": "播放",