mirror of
https://github.com/langgenius/dify.git
synced 2026-09-05 00:31:19 +08:00
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:
parent
66f63a074b
commit
0fc3791bb6
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 />)
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
@ -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} />
|
||||
)
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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": "تشغيل",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "پخش",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "चलाएं",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "再生",
|
||||
|
||||
@ -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": "재생",
|
||||
|
||||
@ -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": "ອ່ານເພີ່ມເຕີມ",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "Воспроизвести",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "เล่น",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "Відтворити",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "播放",
|
||||
|
||||
@ -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": "播放",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user