Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61c43c95e5 | ||
|
|
9316880ecb | ||
|
|
e39a7b10de | ||
|
|
fd20d4f536 | ||
|
|
796f32baa2 | ||
|
|
a605790a84 | ||
|
|
59bf9019c9 | ||
|
|
5fef0cf07c | ||
|
|
068aced02e | ||
|
|
60bc4eaf3c | ||
|
|
208073e228 | ||
|
|
895af9c1d1 | ||
|
|
2bec40d7be | ||
|
|
6d18502041 | ||
|
|
e1432889cb | ||
|
|
0d65a338ef | ||
|
|
3f2fa4d7de | ||
|
|
0a23c0c386 | ||
|
|
49bd13248f | ||
|
|
c989c3c6ac | ||
|
|
7d4718d8c5 | ||
|
|
3b9176f538 | ||
|
|
dcc660daa2 |
@@ -3245,14 +3245,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/marketplace/plugin-type-switch.tsx": {
|
||||
"jsx_a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx_a11y/no-static-element-interactions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-auth/authorized/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
||||
@@ -156,6 +156,21 @@ export type TemplateDetailResponse = {
|
||||
|
||||
export type DownloadPluginResponse = Blob
|
||||
|
||||
const bannerListContract = base
|
||||
.route({
|
||||
path: '/banners',
|
||||
method: 'GET',
|
||||
})
|
||||
.input(
|
||||
type<{
|
||||
query: {
|
||||
page: 'plugins'
|
||||
language: string
|
||||
}
|
||||
}>(),
|
||||
)
|
||||
.output(type<unknown>())
|
||||
|
||||
const collectionsContract = base
|
||||
.route({
|
||||
path: '/collections',
|
||||
@@ -229,6 +244,9 @@ const downloadPluginContract = base
|
||||
.output(type<DownloadPluginResponse>())
|
||||
|
||||
export const marketplaceRouterContract = {
|
||||
banners: {
|
||||
list: bannerListContract,
|
||||
},
|
||||
collections: collectionsContract,
|
||||
collectionPlugins: collectionPluginsContract,
|
||||
searchAdvanced: searchAdvancedContract,
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
import type { SearchParams } from 'nuqs'
|
||||
import Marketplace from '@/app/components/plugins/marketplace'
|
||||
import AccountDropdown from '@/app/components/header/account-dropdown'
|
||||
import { EmbeddedMarketplace } from '@/app/components/plugins/marketplace/embedded'
|
||||
import { fetchPluginBanners } from '@/app/components/plugins/marketplace/home/banners'
|
||||
import MarketplaceInstallPermissionProvider from '@/app/components/plugins/marketplace/marketplace-install-permission-provider'
|
||||
import { getLocaleOnServer } from '@/i18n-config/server'
|
||||
|
||||
type MarketplacePageProps = {
|
||||
searchParams?: Promise<SearchParams>
|
||||
}
|
||||
const MarketplacePage = async () => {
|
||||
const initialBanners = await getLocaleOnServer()
|
||||
.then(fetchPluginBanners)
|
||||
.catch(() => undefined)
|
||||
|
||||
const MarketplacePage = ({ searchParams }: MarketplacePageProps) => {
|
||||
return (
|
||||
<div
|
||||
id="marketplace-container"
|
||||
className="flex h-full min-h-0 flex-col overflow-y-auto bg-background-default-subtle pr-1"
|
||||
className="flex h-full min-h-0 flex-col overflow-y-auto bg-background-default"
|
||||
>
|
||||
<MarketplaceInstallPermissionProvider>
|
||||
<Marketplace searchParams={searchParams} isMarketplacePlatform showInstallButton />
|
||||
<EmbeddedMarketplace
|
||||
initialBanners={initialBanners}
|
||||
showInstallButton
|
||||
variant="home"
|
||||
homeHeaderActions={
|
||||
<div className="p-0.5">
|
||||
<AccountDropdown />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</MarketplaceInstallPermissionProvider>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { LegacyPluginsSearchParams } from '@/app/components/plugins/plugin-routes'
|
||||
import Marketplace from '@/app/components/plugins/marketplace'
|
||||
import AccountDropdown from '@/app/components/header/account-dropdown'
|
||||
import { EmbeddedMarketplace } from '@/app/components/plugins/marketplace/embedded'
|
||||
import PluginPage from '@/app/components/plugins/plugin-page'
|
||||
import PluginsPanel from '@/app/components/plugins/plugin-page/plugins-panel'
|
||||
import {
|
||||
@@ -65,7 +66,17 @@ const PluginList = async ({ searchParams }: PluginListProps) => {
|
||||
return (
|
||||
<PluginPage
|
||||
plugins={<PluginsPanel />}
|
||||
marketplace={<Marketplace showInstallButton pluginTypeSwitchClassName="top-[60px]" />}
|
||||
marketplace={(
|
||||
<EmbeddedMarketplace
|
||||
showInstallButton
|
||||
variant="home"
|
||||
homeHeaderActions={(
|
||||
<div className="p-0.5">
|
||||
<AccountDropdown />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ type CarouselProps = Readonly<{
|
||||
opts?: CarouselOptions
|
||||
plugins?: CarouselPlugin
|
||||
orientation?: 'horizontal' | 'vertical'
|
||||
overlay?: React.ReactNode
|
||||
}>
|
||||
|
||||
type CarouselContextValue = {
|
||||
@@ -49,7 +50,7 @@ type TCarousel = {
|
||||
>
|
||||
|
||||
const Carousel: TCarousel = React.forwardRef(
|
||||
({ orientation = 'horizontal', opts, plugins, className, children, ...props }, ref) => {
|
||||
({ orientation = 'horizontal', opts, plugins, overlay, className, children, ...props }, ref) => {
|
||||
const [carouselRef, api] = useEmblaCarousel(
|
||||
{ ...opts, axis: orientation === 'horizontal' ? 'x' : 'y' },
|
||||
plugins,
|
||||
@@ -98,6 +99,35 @@ const Carousel: TCarousel = React.forwardRef(
|
||||
canScrollNext,
|
||||
}))
|
||||
|
||||
const carousel = overlay
|
||||
? (
|
||||
<div
|
||||
className={cn('relative', className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
{...props}
|
||||
>
|
||||
{overlay}
|
||||
<div
|
||||
ref={carouselRef}
|
||||
className="overflow-hidden [border-radius:inherit]"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div
|
||||
ref={carouselRef}
|
||||
className={cn('relative overflow-hidden', className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
@@ -112,16 +142,7 @@ const Carousel: TCarousel = React.forwardRef(
|
||||
canScrollNext,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={carouselRef}
|
||||
// onKeyDownCapture={handleKeyDown}
|
||||
className={cn('relative overflow-hidden', className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{carousel}
|
||||
</CarouselContext.Provider>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { PluginBanner } from '../home/banners'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockFetchPluginBanners = vi.fn()
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useLocale: () => 'zh-Hans',
|
||||
}))
|
||||
|
||||
vi.mock('../home/banners', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('../home/banners')>()
|
||||
|
||||
return {
|
||||
...original,
|
||||
fetchPluginBanners: (...args: unknown[]) => mockFetchPluginBanners(...args),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../view', () => ({
|
||||
MarketplaceView: ({
|
||||
banners,
|
||||
showInstallButton,
|
||||
}: {
|
||||
banners: PluginBanner[]
|
||||
showInstallButton: boolean
|
||||
}) => (
|
||||
<div>
|
||||
<p>Trending banners: {banners.length}</p>
|
||||
<p>{showInstallButton ? 'Install enabled' : 'Install disabled'}</p>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
let queryClient: QueryClient
|
||||
|
||||
function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
}
|
||||
|
||||
describe('EmbeddedMarketplace', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
gcTime: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('loads homepage banners on the client for the active locale', async () => {
|
||||
mockFetchPluginBanners.mockResolvedValue([
|
||||
{
|
||||
id: 'banner-1',
|
||||
title: 'Trending',
|
||||
sort: 1,
|
||||
language: 'zh-Hans',
|
||||
style_type: 'blog',
|
||||
content: {
|
||||
blog_title: 'Dify update',
|
||||
link: 'https://dify.ai/blog',
|
||||
link_target_type: 'blog',
|
||||
},
|
||||
},
|
||||
] satisfies PluginBanner[])
|
||||
|
||||
const { EmbeddedMarketplace } = await import('../embedded')
|
||||
|
||||
render(<EmbeddedMarketplace showInstallButton variant="home" />, { wrapper: Wrapper })
|
||||
|
||||
expect(await screen.findByText('Trending banners: 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Install enabled')).toBeInTheDocument()
|
||||
expect(mockFetchPluginBanners).toHaveBeenCalledWith('zh-Hans')
|
||||
})
|
||||
|
||||
it('uses server-rendered homepage banners without requesting them again on hydration', async () => {
|
||||
const initialBanners = [
|
||||
{
|
||||
id: 'banner-1',
|
||||
title: 'Trending',
|
||||
sort: 1,
|
||||
language: 'zh-Hans',
|
||||
style_type: 'blog',
|
||||
content: {
|
||||
blog_title: 'Dify update',
|
||||
link: 'https://dify.ai/blog',
|
||||
link_target_type: 'blog',
|
||||
},
|
||||
},
|
||||
] satisfies PluginBanner[]
|
||||
|
||||
const { EmbeddedMarketplace } = await import('../embedded')
|
||||
|
||||
render(
|
||||
<EmbeddedMarketplace initialBanners={initialBanners} showInstallButton variant="home" />,
|
||||
{ wrapper: Wrapper },
|
||||
)
|
||||
|
||||
expect(screen.getByText('Trending banners: 1')).toBeInTheDocument()
|
||||
expect(mockFetchPluginBanners).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not request homepage banners for the default catalog variant', async () => {
|
||||
const { EmbeddedMarketplace } = await import('../embedded')
|
||||
|
||||
render(<EmbeddedMarketplace variant="default" />, { wrapper: Wrapper })
|
||||
|
||||
expect(screen.getByText('Trending banners: 0')).toBeInTheDocument()
|
||||
expect(mockFetchPluginBanners).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { Provider as JotaiProvider } from 'jotai'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
|
||||
import PluginTypeSwitch from '../plugin-type-switch'
|
||||
import styles from '../plugin-type-switch.module.css'
|
||||
|
||||
vi.mock('#i18n', async () => {
|
||||
const { withSelectorKey } = await import('@/test/i18n-mock')
|
||||
@@ -13,7 +14,7 @@ vi.mock('#i18n', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
const renderSwitch = (searchParams = '') => {
|
||||
const renderSwitch = (searchParams = '', props?: ComponentProps<typeof PluginTypeSwitch>) => {
|
||||
const { wrapper: NuqsWrapper, onUrlUpdate } = createNuqsTestWrapper({ searchParams })
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<JotaiProvider>
|
||||
@@ -21,7 +22,7 @@ const renderSwitch = (searchParams = '') => {
|
||||
</JotaiProvider>
|
||||
)
|
||||
|
||||
return { ...render(<PluginTypeSwitch />, { wrapper: Wrapper }), onUrlUpdate }
|
||||
return { ...render(<PluginTypeSwitch {...props} />, { wrapper: Wrapper }), onUrlUpdate }
|
||||
}
|
||||
|
||||
describe('PluginTypeSwitch', () => {
|
||||
@@ -47,4 +48,28 @@ describe('PluginTypeSwitch', () => {
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('category')).toBe('model')
|
||||
})
|
||||
|
||||
it('exposes the selected category and updates the URL in the home variant', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onUrlUpdate } = renderSwitch('?category=all', { variant: 'home' })
|
||||
const categoryGroup = screen.getByRole('group', { name: 'marketplace.allPlugins' })
|
||||
|
||||
expect(categoryGroup).toHaveClass('w-full', 'justify-start', 'gap-1')
|
||||
const activeCategory = screen.getByRole('button', { name: 'category.all' })
|
||||
const inactiveCategory = screen.getByRole('button', { name: 'category.models' })
|
||||
|
||||
expect(activeCategory).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(activeCategory).toHaveClass(styles.homeItem, styles.homeItemActive)
|
||||
expect(inactiveCategory).toHaveClass(styles.homeItem)
|
||||
expect(inactiveCategory).not.toHaveClass(styles.homeItemActive)
|
||||
expect(screen.getByRole('button', { name: 'categorySingle.datasource' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'categorySingle.agent' })).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'category.models' }))
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls.at(-1)?.[0]
|
||||
expect(update?.searchParams.get('category')).toBe('model')
|
||||
expect(update?.options.scroll).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { PluginBanner } from '../home/banners'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockFetchPluginBanners, mockGetLocaleOnServer } = vi.hoisted(() => ({
|
||||
mockFetchPluginBanners: vi.fn(),
|
||||
mockGetLocaleOnServer: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n-config/server', () => ({
|
||||
getLocaleOnServer: mockGetLocaleOnServer,
|
||||
}))
|
||||
|
||||
vi.mock('../home/banners', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('../home/banners')>()
|
||||
|
||||
return {
|
||||
...original,
|
||||
fetchPluginBanners: mockFetchPluginBanners,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../hydration-server', () => ({
|
||||
HydrateQueryClient: ({ children }: { children: ReactNode }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('../view', () => ({
|
||||
MarketplaceView: ({ banners }: { banners: PluginBanner[] }) => (
|
||||
<p>Server banners: {banners.length}</p>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('Marketplace server entry', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('prefetches localized homepage banners before rendering the standalone view', async () => {
|
||||
mockGetLocaleOnServer.mockResolvedValue('en-US')
|
||||
mockFetchPluginBanners.mockResolvedValue([
|
||||
{
|
||||
id: 'banner-1',
|
||||
title: 'Trending',
|
||||
sort: 1,
|
||||
language: 'en-US',
|
||||
style_type: 'blog',
|
||||
content: {
|
||||
blog_title: 'Dify update',
|
||||
link: 'https://dify.ai/blog',
|
||||
link_target_type: 'blog',
|
||||
},
|
||||
},
|
||||
] satisfies PluginBanner[])
|
||||
|
||||
const { default: Marketplace } = await import('../index')
|
||||
const element = await Marketplace({ variant: 'home' })
|
||||
|
||||
render(element)
|
||||
|
||||
expect(screen.getByText('Server banners: 1')).toBeInTheDocument()
|
||||
expect(mockGetLocaleOnServer).toHaveBeenCalledOnce()
|
||||
expect(mockFetchPluginBanners).toHaveBeenCalledWith('en-US')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
'use client'
|
||||
|
||||
import type { PluginBanner } from './home/banners'
|
||||
import type { MarketplaceViewProps } from './view'
|
||||
import { queryOptions, useQuery } from '@tanstack/react-query'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { marketplaceQuery } from '@/service/client'
|
||||
import { fetchPluginBanners } from './home/banners'
|
||||
import { MarketplaceView } from './view'
|
||||
|
||||
const BANNER_STALE_TIME = 1000 * 60 * 5
|
||||
|
||||
export type EmbeddedMarketplaceProps = Omit<MarketplaceViewProps, 'banners'> & {
|
||||
initialBanners?: PluginBanner[]
|
||||
}
|
||||
|
||||
export function EmbeddedMarketplace({
|
||||
initialBanners,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: EmbeddedMarketplaceProps) {
|
||||
const locale = useLocale()
|
||||
const input = {
|
||||
query: {
|
||||
page: 'plugins' as const,
|
||||
language: locale,
|
||||
},
|
||||
}
|
||||
const { data: banners = [] } = useQuery(
|
||||
queryOptions({
|
||||
queryKey: [...marketplaceQuery.banners.list.queryKey({ input }), locale],
|
||||
queryFn: () => fetchPluginBanners(locale),
|
||||
enabled: variant === 'home',
|
||||
initialData: initialBanners,
|
||||
staleTime: BANNER_STALE_TIME,
|
||||
}),
|
||||
)
|
||||
|
||||
return <MarketplaceView {...props} banners={banners} variant={variant} />
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
# Marketplace Catalog Home
|
||||
|
||||
The redesigned Marketplace catalog shell provides the shared header, hero, search, trending, tabs, and sticky category navigation used by the Plugins and Templates pages.
|
||||
|
||||
## Internal Modules
|
||||
|
||||
- `marketplace/list/list-wrapper`
|
||||
- `marketplace/plugin-type-switch`
|
||||
|
||||
## External Modules
|
||||
|
||||
None.
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import HomeCatalogNavigation from '../home-catalog-navigation'
|
||||
import HomeCatalogTabs from '../home-catalog-tabs'
|
||||
import { HomeStickyCatalogTabs, HomeStickyStateProvider } from '../home-sticky-state-provider'
|
||||
import styles from '../home-sticky.module.css'
|
||||
|
||||
vi.mock('#i18n', async () => {
|
||||
const { withSelectorKey } = await import('@/test/i18n-mock')
|
||||
return {
|
||||
useTranslation: () => ({
|
||||
t: withSelectorKey((key: string, options?: { ns?: string }) =>
|
||||
options?.ns ? `${options.ns}.${key}` : key,
|
||||
),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../plugin-type-switch', () => ({
|
||||
default: ({ className, variant }: { className?: string; variant?: string }) => (
|
||||
<div data-testid="plugin-type-switch" className={className} data-variant={variant} />
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/var', () => ({
|
||||
getMarketplaceUrl: (path: string) => `https://marketplace.dify.ai${path}?source=console`,
|
||||
}))
|
||||
|
||||
describe('HomeCatalogNavigation', () => {
|
||||
const renderNavigation = (isMarketplacePlatform: boolean) => {
|
||||
return render(
|
||||
<HomeStickyStateProvider>
|
||||
<HomeStickyCatalogTabs>
|
||||
<div data-testid="header-catalog-tabs" />
|
||||
</HomeStickyCatalogTabs>
|
||||
<HomeCatalogNavigation
|
||||
catalogTabs={<HomeCatalogTabs isMarketplacePlatform={isMarketplacePlatform} />}
|
||||
/>
|
||||
</HomeStickyStateProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
it('keeps template navigation inside the Marketplace platform', () => {
|
||||
renderNavigation(true)
|
||||
|
||||
const navigationSection = screen.getByRole('region', { name: 'common.mainNav.marketplace' })
|
||||
|
||||
expect(navigationSection).toHaveClass(styles.catalogNavigation)
|
||||
expect(navigationSection.firstElementChild).toHaveClass('w-full')
|
||||
expect(navigationSection.firstElementChild).not.toHaveClass('mx-auto', 'max-w-[1200px]')
|
||||
const activeTab = screen.getByRole('link', { name: 'plugin.marketplace.home.plugins' })
|
||||
expect(activeTab).toHaveAttribute('aria-current', 'page')
|
||||
expect(activeTab).toHaveAttribute('href', '/plugins')
|
||||
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')
|
||||
})
|
||||
|
||||
it('keeps header tabs clickable', () => {
|
||||
render(<HomeCatalogTabs isMarketplacePlatform />)
|
||||
|
||||
const pluginsTab = screen.getByRole('link', { name: 'plugin.marketplace.home.plugins' })
|
||||
const templatesTab = screen.getByRole('link', { name: 'plugin.marketplace.home.templates' })
|
||||
|
||||
expect(pluginsTab).toHaveAttribute('href', '/plugins')
|
||||
expect(pluginsTab).toHaveClass('cursor-pointer')
|
||||
expect(templatesTab).toHaveAttribute('href', '/templates')
|
||||
expect(templatesTab).toHaveClass('cursor-pointer')
|
||||
})
|
||||
|
||||
it('marks Templates as active when rendering the Templates catalog', () => {
|
||||
render(<HomeCatalogTabs activeTab="templates" isMarketplacePlatform />)
|
||||
|
||||
const pluginsTab = screen.getByRole('link', { name: 'plugin.marketplace.home.plugins' })
|
||||
const templatesTab = screen.getByRole('link', { name: 'plugin.marketplace.home.templates' })
|
||||
|
||||
expect(pluginsTab).not.toHaveAttribute('aria-current')
|
||||
expect(templatesTab).toHaveAttribute('aria-current', 'page')
|
||||
expect(screen.queryByText('plugin.marketplace.home.new')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders a supplied catalog category navigation', () => {
|
||||
render(
|
||||
<HomeStickyStateProvider>
|
||||
<HomeCatalogNavigation
|
||||
catalogTabs={<HomeCatalogTabs activeTab="templates" isMarketplacePlatform />}
|
||||
catalogCategories={<nav aria-label="Template categories">Template categories</nav>}
|
||||
/>
|
||||
</HomeStickyStateProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('navigation', { name: 'Template categories' })).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('plugin-type-switch')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('links Dify users to the hosted Marketplace templates page', () => {
|
||||
renderNavigation(false)
|
||||
|
||||
expect(screen.getByRole('link', { name: 'plugin.marketplace.home.plugins' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://marketplace.dify.ai/plugins?source=console',
|
||||
)
|
||||
expect(
|
||||
screen.getByRole('link', { name: /plugin\.marketplace\.home\.templates/ }),
|
||||
).toHaveAttribute('href', 'https://marketplace.dify.ai/templates?source=console')
|
||||
})
|
||||
|
||||
it('shows the compact navigation and header tabs after reaching the sticky header', () => {
|
||||
const scrollContainer = document.createElement('div')
|
||||
scrollContainer.id = 'marketplace-container'
|
||||
document.body.appendChild(scrollContainer)
|
||||
|
||||
renderNavigation(true)
|
||||
|
||||
const navigationSection = screen.getByRole('region', { name: 'common.mainNav.marketplace' })
|
||||
const pinTrigger = navigationSection.previousElementSibling as HTMLElement
|
||||
vi.spyOn(scrollContainer, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 100, 100))
|
||||
const triggerRect = vi
|
||||
.spyOn(pinTrigger, 'getBoundingClientRect')
|
||||
.mockReturnValue(new DOMRect(0, 49, 100, 100))
|
||||
|
||||
fireEvent.scroll(scrollContainer)
|
||||
expect(screen.queryByTestId('header-catalog-tabs')).not.toBeInTheDocument()
|
||||
|
||||
triggerRect.mockReturnValue(new DOMRect(0, 48, 100, 100))
|
||||
fireEvent.scroll(scrollContainer)
|
||||
|
||||
expect(navigationSection).toHaveClass(styles.catalogNavigationPinned)
|
||||
expect(
|
||||
screen.getByRole('navigation', { name: 'common.mainNav.marketplace' }).parentElement,
|
||||
).toHaveClass(styles.catalogTabsPinned)
|
||||
expect(screen.getByTestId('plugin-type-switch')).toHaveClass(styles.categoriesPinned)
|
||||
expect(screen.getByTestId('header-catalog-tabs')).toBeInTheDocument()
|
||||
|
||||
triggerRect.mockReturnValue(new DOMRect(0, 49, 100, 100))
|
||||
fireEvent.scroll(scrollContainer)
|
||||
|
||||
expect(navigationSection).not.toHaveClass(styles.catalogNavigationPinned)
|
||||
expect(screen.queryByTestId('header-catalog-tabs')).not.toBeInTheDocument()
|
||||
|
||||
scrollContainer.remove()
|
||||
})
|
||||
|
||||
it('keeps the pinned state when compact styling moves the sticky section', () => {
|
||||
const scrollContainer = document.createElement('div')
|
||||
scrollContainer.id = 'marketplace-container'
|
||||
document.body.appendChild(scrollContainer)
|
||||
|
||||
renderNavigation(true)
|
||||
|
||||
const navigationSection = screen.getByRole('region', { name: 'common.mainNav.marketplace' })
|
||||
const pinTrigger = navigationSection.previousElementSibling as HTMLElement
|
||||
vi.spyOn(scrollContainer, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 100, 100))
|
||||
vi.spyOn(pinTrigger, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 47, 100, 0))
|
||||
vi.spyOn(navigationSection, 'getBoundingClientRect').mockReturnValue(
|
||||
new DOMRect(0, 49, 100, 60),
|
||||
)
|
||||
|
||||
fireEvent.scroll(scrollContainer)
|
||||
|
||||
expect(navigationSection).toHaveClass(styles.catalogNavigationPinned)
|
||||
expect(screen.getByTestId('header-catalog-tabs')).toBeInTheDocument()
|
||||
|
||||
scrollContainer.remove()
|
||||
})
|
||||
|
||||
it('prevents scroll anchoring from reversing the sticky threshold', () => {
|
||||
const scrollContainer = document.createElement('div')
|
||||
scrollContainer.id = 'marketplace-container'
|
||||
document.body.appendChild(scrollContainer)
|
||||
|
||||
const { unmount } = renderNavigation(true)
|
||||
|
||||
expect(scrollContainer.style.overflowAnchor).toBe('none')
|
||||
|
||||
unmount()
|
||||
expect(scrollContainer.style.overflowAnchor).toBe('')
|
||||
|
||||
scrollContainer.remove()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import HomeHeader from '../home-header'
|
||||
|
||||
vi.mock('#i18n', async () => {
|
||||
const { withSelectorKey } = await import('@/test/i18n-mock')
|
||||
return {
|
||||
useTranslation: () => ({
|
||||
t: withSelectorKey((key: string) => key),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => () => 'https://docs.dify.ai/en/home',
|
||||
}))
|
||||
|
||||
vi.mock('../home-sticky-state-provider', () => ({
|
||||
HomeStickyCatalogTabs: ({ children }: { children: React.ReactNode }) => children,
|
||||
}))
|
||||
|
||||
describe('HomeHeader', () => {
|
||||
it('links the Guide action to Dify documentation', () => {
|
||||
render(<HomeHeader isMarketplacePlatform />)
|
||||
|
||||
const brandLink = screen.getByRole('link', { name: 'Dify Marketplace' })
|
||||
const [lightLogo, darkLogo] = brandLink.querySelectorAll('img')
|
||||
expect(lightLogo).toHaveAttribute('src', expect.stringContaining('dify-marketplace-logo.svg'))
|
||||
expect(darkLogo).toHaveAttribute(
|
||||
'src',
|
||||
expect.stringContaining('dify-marketplace-logo-dark.svg'),
|
||||
)
|
||||
expect(lightLogo).toHaveAttribute('width', '141.761')
|
||||
expect(lightLogo).toHaveAttribute('height', '16.386')
|
||||
expect(darkLogo).toHaveAttribute('width', '141.761')
|
||||
expect(darkLogo).toHaveAttribute('height', '16.386')
|
||||
expect(screen.queryByText('mainNav.marketplace')).not.toBeInTheDocument()
|
||||
|
||||
const guideLink = screen.getByRole('link', { name: 'marketplace.home.guide' })
|
||||
expect(guideLink).toHaveAttribute('href', 'https://docs.dify.ai/en/home')
|
||||
expect(guideLink).toHaveAttribute('target', '_blank')
|
||||
expect(guideLink).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
|
||||
it('shows Templates as the active compact tab on the Templates catalog', () => {
|
||||
render(<HomeHeader activeTab="templates" isMarketplacePlatform />)
|
||||
|
||||
expect(screen.getByRole('link', { name: 'marketplace.home.plugins' })).not.toHaveAttribute(
|
||||
'aria-current',
|
||||
)
|
||||
expect(screen.getByRole('link', { name: 'marketplace.home.templates' })).toHaveAttribute(
|
||||
'aria-current',
|
||||
'page',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import HomeHero from '../home-hero'
|
||||
|
||||
vi.mock('#i18n', async () => {
|
||||
const { withSelectorKey } = await import('@/test/i18n-mock')
|
||||
return {
|
||||
useTranslation: () => ({
|
||||
t: withSelectorKey((key: string) => key),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
describe('HomeHero', () => {
|
||||
it('renders catalog-specific copy when supplied', () => {
|
||||
render(
|
||||
<HomeHero
|
||||
isMarketplacePlatform
|
||||
title="Discover templates"
|
||||
subtitle="Start faster with ready-to-use workflows."
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Discover templates' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Start faster with ready-to-use workflows.')).toBeInTheDocument()
|
||||
expect(screen.queryByText('marketplace.home.heroTitle')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,208 @@
|
||||
import type { PluginBanner } from '../banners'
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import HomeTrending from '../home-trending'
|
||||
|
||||
vi.mock('#i18n', async () => {
|
||||
const { withSelectorKey } = await import('@/test/i18n-mock')
|
||||
return {
|
||||
useTranslation: (namespace: string) => ({
|
||||
t: withSelectorKey((key: string) => `${namespace}.${key}`),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/plugins/base/badges/partner', () => ({
|
||||
default: () => <span data-testid="partner-badge" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/base/badges/verified', () => ({
|
||||
default: () => <span data-testid="verified-badge" />,
|
||||
}))
|
||||
|
||||
const banners: PluginBanner[] = [
|
||||
{
|
||||
id: 'recommend',
|
||||
style_type: 'recommend',
|
||||
title: 'Trending',
|
||||
sort: 0,
|
||||
language: 'en',
|
||||
content: {
|
||||
theme_type: 'hottest',
|
||||
heading: 'Popular plugins',
|
||||
description: 'Chosen from real usage.',
|
||||
cards: [
|
||||
{
|
||||
item_type: 'plugin',
|
||||
item_id: 'langgenius/dropbox',
|
||||
display_name: 'Dropbox',
|
||||
icon_url: '/api/v1/plugins/langgenius/dropbox/icon',
|
||||
creator: 'langgenius',
|
||||
badges: ['partner', 'verified'],
|
||||
link: '/plugins/langgenius/dropbox',
|
||||
card_position: 0,
|
||||
},
|
||||
{
|
||||
item_type: 'plugin',
|
||||
item_id: 'langgenius/zapier',
|
||||
display_name: 'Zapier',
|
||||
link: '/plugins/langgenius/zapier',
|
||||
card_position: 1,
|
||||
},
|
||||
{
|
||||
item_type: 'plugin',
|
||||
item_id: 'langgenius/notion',
|
||||
display_name: 'Notion',
|
||||
link: '/plugins/langgenius/notion',
|
||||
card_position: 2,
|
||||
},
|
||||
{
|
||||
item_type: 'plugin',
|
||||
item_id: 'langgenius/slack',
|
||||
display_name: 'Slack',
|
||||
link: '/plugins/langgenius/slack',
|
||||
card_position: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'blog',
|
||||
style_type: 'blog',
|
||||
title: 'Dify Updates',
|
||||
sort: 1,
|
||||
language: 'en',
|
||||
content: {
|
||||
blog_title: 'Dify v1.9 new launch',
|
||||
subtitle: 'New Agent node support',
|
||||
description: 'Build agent workflows with the new Agent node.',
|
||||
link: 'https://dify.ai/blog',
|
||||
link_target_type: 'blog',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'event',
|
||||
style_type: 'event',
|
||||
title: 'Duck Duck Go',
|
||||
sort: 2,
|
||||
language: 'en',
|
||||
content: {
|
||||
images: {
|
||||
desktop: '/api/v1/banners/images/banners/duckduckgo.png',
|
||||
mobile: '/api/v1/banners/images/banners/duckduckgo-mobile.png',
|
||||
},
|
||||
link: 'https://marketplace.dify.ai/plugin/langgenius/duckduckgo',
|
||||
alt_text: 'DuckDuckGo plugin',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
describe('HomeTrending', () => {
|
||||
it('renders and switches between the three API-backed banner layouts', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<HomeTrending banners={banners} isMarketplacePlatform />)
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Popular plugins' })).toBeInTheDocument()
|
||||
const recommendationSlide = screen.getByRole('group', { name: 'Trending' })
|
||||
expect(
|
||||
within(recommendationSlide)
|
||||
.getAllByRole('link')
|
||||
.map((link) => link.getAttribute('aria-label')),
|
||||
).toEqual(['Dropbox', 'Zapier', 'Notion', 'Slack'])
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Dify Updates' }))
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Dify v1.9 new launch' })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('link', {
|
||||
name: 'Read more about Dify v1.9 new launch',
|
||||
}),
|
||||
).toHaveAttribute('href', 'https://dify.ai/blog')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Duck Duck Go' }))
|
||||
|
||||
expect(screen.getByRole('link', { name: 'DuckDuckGo plugin' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://marketplace.dify.ai/plugin/langgenius/duckduckgo',
|
||||
)
|
||||
})
|
||||
|
||||
it('switches to the selected slide from the pagination with the keyboard', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<HomeTrending banners={banners} isMarketplacePlatform />)
|
||||
|
||||
const duckDuckGoButton = screen.getByRole('button', { name: 'Duck Duck Go' })
|
||||
|
||||
duckDuckGoButton.focus()
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(duckDuckGoButton).toHaveAttribute('aria-current', 'true')
|
||||
expect(screen.getByRole('button', { name: 'Trending' })).not.toHaveAttribute('aria-current')
|
||||
expect(screen.getByRole('group', { name: 'Duck Duck Go' })).toHaveAttribute(
|
||||
'aria-hidden',
|
||||
'false',
|
||||
)
|
||||
})
|
||||
|
||||
it('toggles the carousel between paused and playing states', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<HomeTrending banners={banners} isMarketplacePlatform />)
|
||||
|
||||
const pauseButton = screen.getByRole('button', {
|
||||
name: 'plugin.marketplace.home.trendingPause',
|
||||
})
|
||||
|
||||
pauseButton.focus()
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
const playButton = screen.getByRole('button', {
|
||||
name: 'plugin.marketplace.home.trendingPlay',
|
||||
})
|
||||
|
||||
playButton.focus()
|
||||
await user.keyboard(' ')
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: 'plugin.marketplace.home.trendingPause',
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('starts with autoplay paused when reduced motion is enabled', () => {
|
||||
const matchMedia = vi.spyOn(window, 'matchMedia').mockReturnValue({
|
||||
matches: true,
|
||||
media: '(prefers-reduced-motion: reduce)',
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})
|
||||
|
||||
render(<HomeTrending banners={banners} isMarketplacePlatform />)
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: 'plugin.marketplace.home.trendingPlay',
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
|
||||
matchMedia.mockRestore()
|
||||
})
|
||||
|
||||
it('renders no carousel when the API returns no banners', () => {
|
||||
render(<HomeTrending banners={[]} isMarketplacePlatform />)
|
||||
|
||||
expect(
|
||||
screen.queryByRole('region', {
|
||||
name: 'plugin.marketplace.home.trendingTitle',
|
||||
}),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.9 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,200 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { marketplaceClient } from '@/service/client'
|
||||
import { fetchPluginBanners } from './banners'
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
marketplaceClient: {
|
||||
banners: {
|
||||
list: vi.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const mockedListBanners = vi.mocked(marketplaceClient.banners.list)
|
||||
|
||||
describe('fetchPluginBanners', () => {
|
||||
beforeEach(() => {
|
||||
mockedListBanners.mockReset()
|
||||
})
|
||||
|
||||
it('normalizes every public banner style in API sort order', async () => {
|
||||
mockedListBanners.mockResolvedValue({
|
||||
code: 0,
|
||||
msg: 'success',
|
||||
data: {
|
||||
banners: [
|
||||
{
|
||||
id: 'event',
|
||||
style_type: 'event',
|
||||
title: 'Dify Event',
|
||||
sort: 3,
|
||||
language: 'en',
|
||||
content: {
|
||||
images: {
|
||||
desktop: '/api/v1/banners/images/banners/event.png',
|
||||
mobile: '/api/v1/banners/images/banners/event-mobile.png',
|
||||
},
|
||||
link: 'https://dify.ai/events',
|
||||
alt_text: 'Dify Event',
|
||||
activity_id: 'event-1',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'recommend',
|
||||
style_type: 'recommend',
|
||||
title: 'Trending Now',
|
||||
sort: 1,
|
||||
language: 'en',
|
||||
content: {
|
||||
theme_type: 'hottest',
|
||||
heading: 'Popular plugins',
|
||||
description: 'Chosen from real usage.',
|
||||
cards: [
|
||||
{
|
||||
item_type: 'plugin',
|
||||
item_id: 'langgenius/fourth',
|
||||
display_name: 'Fourth',
|
||||
link: '/plugins/langgenius/fourth',
|
||||
card_position: 3,
|
||||
},
|
||||
{
|
||||
item_type: 'plugin',
|
||||
item_id: 'langgenius/first',
|
||||
display_name: 'First',
|
||||
icon_url: '/api/v1/plugins/langgenius/first/icon',
|
||||
creator: 'langgenius',
|
||||
badges: ['verified', 'partner', 'unknown'],
|
||||
link: '/plugins/langgenius/first',
|
||||
card_position: 0,
|
||||
},
|
||||
{
|
||||
item_type: 'plugin',
|
||||
item_id: 'langgenius/third',
|
||||
display_name: 'Third',
|
||||
link: '/plugins/langgenius/third',
|
||||
card_position: 2,
|
||||
},
|
||||
{
|
||||
item_type: 'plugin',
|
||||
item_id: 'langgenius/second',
|
||||
display_name: 'Second',
|
||||
link: '/plugins/langgenius/second',
|
||||
card_position: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ad',
|
||||
style_type: 'ad',
|
||||
title: 'Partner campaign',
|
||||
sort: 4,
|
||||
language: 'en',
|
||||
content: {
|
||||
images: {
|
||||
desktop: '/api/v1/banners/images/banners/ad.webp',
|
||||
},
|
||||
link: 'https://example.com',
|
||||
partner_id: 'partner-1',
|
||||
campaign_id: 'campaign-1',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'blog',
|
||||
style_type: 'blog',
|
||||
title: 'Dify Updates',
|
||||
sort: 2,
|
||||
language: 'en',
|
||||
content: {
|
||||
blog_title: 'Dify v1.9 new launch',
|
||||
subtitle: 'New Agent node support',
|
||||
description: 'Build agent workflows with the new Agent node.',
|
||||
link: 'https://dify.ai/blog',
|
||||
link_target_type: 'blog',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'unsupported',
|
||||
style_type: 'popup',
|
||||
title: 'Unsupported',
|
||||
sort: 0,
|
||||
language: 'en',
|
||||
content: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const banners = await fetchPluginBanners('en-US')
|
||||
|
||||
expect(mockedListBanners).toHaveBeenCalledWith({
|
||||
query: {
|
||||
page: 'plugins',
|
||||
language: 'en-US',
|
||||
},
|
||||
})
|
||||
expect(banners.map((banner) => banner.id)).toEqual(['recommend', 'blog', 'event', 'ad'])
|
||||
|
||||
const recommend = banners[0]
|
||||
expect(recommend?.style_type).toBe('recommend')
|
||||
if (recommend?.style_type === 'recommend') {
|
||||
expect(recommend.content.cards.map((card) => card.display_name)).toEqual([
|
||||
'First',
|
||||
'Second',
|
||||
'Third',
|
||||
'Fourth',
|
||||
])
|
||||
expect(recommend.content.cards[0]).toMatchObject({
|
||||
creator: 'langgenius',
|
||||
badges: ['verified', 'partner'],
|
||||
})
|
||||
}
|
||||
|
||||
const event = banners[2]
|
||||
expect(event?.style_type).toBe('event')
|
||||
if (event?.style_type === 'event') {
|
||||
expect(event.content.images).toEqual({
|
||||
desktop: '/api/v1/banners/images/banners/event.png',
|
||||
mobile: '/api/v1/banners/images/banners/event-mobile.png',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('drops malformed banners and returns no placeholders for an empty response', async () => {
|
||||
mockedListBanners
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
banners: [
|
||||
{
|
||||
id: 'empty-recommend',
|
||||
style_type: 'recommend',
|
||||
title: 'Empty',
|
||||
sort: 0,
|
||||
language: 'en',
|
||||
content: {
|
||||
theme_type: 'hottest',
|
||||
cards: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'event-without-desktop',
|
||||
style_type: 'event',
|
||||
title: 'Broken',
|
||||
sort: 1,
|
||||
language: 'en',
|
||||
content: {
|
||||
images: {
|
||||
mobile: '/api/v1/banners/images/banners/mobile.png',
|
||||
},
|
||||
link: 'https://example.com',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce('')
|
||||
|
||||
await expect(fetchPluginBanners('en-US')).resolves.toEqual([])
|
||||
await expect(fetchPluginBanners('en-US')).resolves.toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,282 @@
|
||||
import { marketplaceClient } from '@/service/client'
|
||||
|
||||
const MAX_CARDS_PER_PAGE = 4
|
||||
|
||||
type BannerBase = {
|
||||
id: string
|
||||
title: string
|
||||
sort: number
|
||||
language: string
|
||||
}
|
||||
|
||||
export type BannerRecommendCard = {
|
||||
item_type: 'plugin' | 'template'
|
||||
item_id: string
|
||||
display_name: string
|
||||
icon_url?: string
|
||||
icon?: string
|
||||
icon_background?: string
|
||||
creator?: string
|
||||
badges?: Array<'partner' | 'verified'>
|
||||
link: string
|
||||
card_position: number
|
||||
}
|
||||
|
||||
export type BannerRecommend = BannerBase & {
|
||||
style_type: 'recommend'
|
||||
content: {
|
||||
theme_type: 'newest' | 'hottest' | 'partner'
|
||||
heading?: string
|
||||
subheadings?: string[]
|
||||
description?: string
|
||||
cards: BannerRecommendCard[]
|
||||
}
|
||||
}
|
||||
|
||||
export type BannerBlog = BannerBase & {
|
||||
style_type: 'blog'
|
||||
content: {
|
||||
blog_title: string
|
||||
subtitle?: string
|
||||
description?: string
|
||||
link: string
|
||||
link_target_type: 'blog' | 'github'
|
||||
}
|
||||
}
|
||||
|
||||
type BannerImageContent = {
|
||||
images: {
|
||||
desktop: string
|
||||
tablet?: string
|
||||
mobile?: string
|
||||
}
|
||||
link: string
|
||||
alt_text?: string
|
||||
activity_id?: string
|
||||
}
|
||||
|
||||
export type BannerEvent = BannerBase & {
|
||||
style_type: 'event'
|
||||
content: BannerImageContent
|
||||
}
|
||||
|
||||
export type BannerAd = BannerBase & {
|
||||
style_type: 'ad'
|
||||
content: BannerImageContent & {
|
||||
partner_id?: string
|
||||
campaign_id?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type PluginBanner = BannerRecommend | BannerBlog | BannerEvent | BannerAd
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
const parseBannerBase = (value: Record<string, unknown>): BannerBase | null => {
|
||||
if (
|
||||
typeof value.id !== 'string' ||
|
||||
!value.id ||
|
||||
typeof value.title !== 'string' ||
|
||||
!value.title ||
|
||||
typeof value.sort !== 'number' ||
|
||||
typeof value.language !== 'string' ||
|
||||
!value.language
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: value.id,
|
||||
title: value.title,
|
||||
sort: value.sort,
|
||||
language: value.language,
|
||||
}
|
||||
}
|
||||
|
||||
const parseRecommendCard = (value: unknown): BannerRecommendCard | null => {
|
||||
if (!isRecord(value)) return null
|
||||
|
||||
const itemType = value.item_type
|
||||
const itemId = value.item_id
|
||||
const displayName = value.display_name
|
||||
if (
|
||||
(itemType !== 'plugin' && itemType !== 'template') ||
|
||||
typeof itemId !== 'string' ||
|
||||
!itemId ||
|
||||
typeof displayName !== 'string' ||
|
||||
!displayName
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const badges = Array.isArray(value.badges)
|
||||
? value.badges.filter(
|
||||
(badge): badge is 'partner' | 'verified' => badge === 'partner' || badge === 'verified',
|
||||
)
|
||||
: undefined
|
||||
|
||||
return {
|
||||
item_type: itemType,
|
||||
item_id: itemId,
|
||||
display_name: displayName,
|
||||
icon_url: typeof value.icon_url === 'string' ? value.icon_url : undefined,
|
||||
icon: typeof value.icon === 'string' ? value.icon : undefined,
|
||||
icon_background: typeof value.icon_background === 'string' ? value.icon_background : undefined,
|
||||
creator: typeof value.creator === 'string' ? value.creator : undefined,
|
||||
badges,
|
||||
link: typeof value.link === 'string' ? value.link : '',
|
||||
card_position: typeof value.card_position === 'number' ? value.card_position : 0,
|
||||
}
|
||||
}
|
||||
|
||||
const parseRecommendBanner = (
|
||||
base: BannerBase,
|
||||
content: Record<string, unknown>,
|
||||
): BannerRecommend | null => {
|
||||
const themeType = content.theme_type
|
||||
if (themeType !== 'newest' && themeType !== 'hottest' && themeType !== 'partner') return null
|
||||
|
||||
const cards = Array.isArray(content.cards)
|
||||
? content.cards
|
||||
.map(parseRecommendCard)
|
||||
.filter((card): card is BannerRecommendCard => Boolean(card))
|
||||
.sort((a, b) => a.card_position - b.card_position)
|
||||
.slice(0, MAX_CARDS_PER_PAGE)
|
||||
: []
|
||||
|
||||
if (cards.length === 0) return null
|
||||
|
||||
const subheadings = Array.isArray(content.subheadings)
|
||||
? content.subheadings.filter((item): item is string => typeof item === 'string')
|
||||
: undefined
|
||||
|
||||
return {
|
||||
...base,
|
||||
style_type: 'recommend',
|
||||
content: {
|
||||
theme_type: themeType,
|
||||
heading: typeof content.heading === 'string' ? content.heading : undefined,
|
||||
subheadings,
|
||||
description: typeof content.description === 'string' ? content.description : undefined,
|
||||
cards,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const parseBlogBanner = (base: BannerBase, content: Record<string, unknown>): BannerBlog | null => {
|
||||
const linkTargetType = content.link_target_type
|
||||
if (
|
||||
typeof content.blog_title !== 'string' ||
|
||||
!content.blog_title ||
|
||||
typeof content.link !== 'string' ||
|
||||
!content.link ||
|
||||
(linkTargetType !== 'blog' && linkTargetType !== 'github')
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
style_type: 'blog',
|
||||
content: {
|
||||
blog_title: content.blog_title,
|
||||
subtitle: typeof content.subtitle === 'string' ? content.subtitle : undefined,
|
||||
description: typeof content.description === 'string' ? content.description : undefined,
|
||||
link: content.link,
|
||||
link_target_type: linkTargetType,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const parseImageBanner = (
|
||||
base: BannerBase,
|
||||
styleType: 'event' | 'ad',
|
||||
content: Record<string, unknown>,
|
||||
): BannerEvent | BannerAd | null => {
|
||||
if (
|
||||
!isRecord(content.images) ||
|
||||
typeof content.images.desktop !== 'string' ||
|
||||
!content.images.desktop ||
|
||||
typeof content.link !== 'string' ||
|
||||
!content.link
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const imageContent: BannerImageContent = {
|
||||
images: {
|
||||
desktop: content.images.desktop,
|
||||
tablet:
|
||||
typeof content.images.tablet === 'string' && content.images.tablet
|
||||
? content.images.tablet
|
||||
: undefined,
|
||||
mobile:
|
||||
typeof content.images.mobile === 'string' && content.images.mobile
|
||||
? content.images.mobile
|
||||
: undefined,
|
||||
},
|
||||
link: content.link,
|
||||
alt_text: typeof content.alt_text === 'string' ? content.alt_text : undefined,
|
||||
activity_id: typeof content.activity_id === 'string' ? content.activity_id : undefined,
|
||||
}
|
||||
|
||||
if (styleType === 'event') {
|
||||
return {
|
||||
...base,
|
||||
style_type: 'event',
|
||||
content: imageContent,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
style_type: 'ad',
|
||||
content: {
|
||||
...imageContent,
|
||||
partner_id: typeof content.partner_id === 'string' ? content.partner_id : undefined,
|
||||
campaign_id: typeof content.campaign_id === 'string' ? content.campaign_id : undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const parsePluginBanner = (value: unknown): PluginBanner | null => {
|
||||
if (!isRecord(value) || !isRecord(value.content)) return null
|
||||
|
||||
const base = parseBannerBase(value)
|
||||
if (!base) return null
|
||||
|
||||
switch (value.style_type) {
|
||||
case 'recommend':
|
||||
return parseRecommendBanner(base, value.content)
|
||||
case 'blog':
|
||||
return parseBlogBanner(base, value.content)
|
||||
case 'event':
|
||||
case 'ad':
|
||||
return parseImageBanner(base, value.style_type, value.content)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const normalizePluginBanners = (response: unknown): PluginBanner[] => {
|
||||
if (!isRecord(response) || !isRecord(response.data) || !Array.isArray(response.data.banners))
|
||||
return []
|
||||
|
||||
return response.data.banners
|
||||
.map(parsePluginBanner)
|
||||
.filter((banner): banner is PluginBanner => Boolean(banner))
|
||||
.sort((a, b) => a.sort - b.sort)
|
||||
}
|
||||
|
||||
export const fetchPluginBanners = async (language: string): Promise<PluginBanner[]> => {
|
||||
const response = await marketplaceClient.banners.list({
|
||||
query: {
|
||||
page: 'plugins',
|
||||
language,
|
||||
},
|
||||
})
|
||||
|
||||
return normalizePluginBanners(response)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useTranslation } from '#i18n'
|
||||
import PluginTypeSwitch from '../plugin-type-switch'
|
||||
import { homeCatalogPinnedAtom } from './home-sticky-state'
|
||||
import styles from './home-sticky.module.css'
|
||||
|
||||
type HomeCatalogNavigationProps = {
|
||||
catalogCategories?: ReactNode
|
||||
catalogTabs: ReactNode
|
||||
}
|
||||
|
||||
const STICKY_TOP = 48
|
||||
|
||||
function HomeCatalogNavigation({ catalogCategories, catalogTabs }: HomeCatalogNavigationProps) {
|
||||
const { t } = useTranslation()
|
||||
const isPinned = useAtomValue(homeCatalogPinnedAtom)
|
||||
const setIsPinned = useSetAtom(homeCatalogPinnedAtom)
|
||||
const pinTriggerRef = useRef<HTMLSpanElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const scrollContainer = document.getElementById('marketplace-container')
|
||||
if (!scrollContainer) return
|
||||
|
||||
const previousOverflowAnchor = scrollContainer.style.overflowAnchor
|
||||
// The sticky section becomes shorter when its tabs move into the header.
|
||||
// Prevent browser scroll anchoring from moving it back across the pin threshold.
|
||||
scrollContainer.style.overflowAnchor = 'none'
|
||||
|
||||
const updatePinnedState = () => {
|
||||
const pinTrigger = pinTriggerRef.current
|
||||
if (!pinTrigger) return
|
||||
|
||||
const containerTop = scrollContainer.getBoundingClientRect().top
|
||||
const triggerTop = pinTrigger.getBoundingClientRect().top
|
||||
setIsPinned(triggerTop <= containerTop + STICKY_TOP)
|
||||
}
|
||||
|
||||
updatePinnedState()
|
||||
scrollContainer.addEventListener('scroll', updatePinnedState, { passive: true })
|
||||
window.addEventListener('resize', updatePinnedState)
|
||||
|
||||
return () => {
|
||||
scrollContainer.removeEventListener('scroll', updatePinnedState)
|
||||
window.removeEventListener('resize', updatePinnedState)
|
||||
scrollContainer.style.overflowAnchor = previousOverflowAnchor
|
||||
}
|
||||
}, [setIsPinned])
|
||||
|
||||
return (
|
||||
<>
|
||||
<span ref={pinTriggerRef} aria-hidden className={styles.catalogNavigationTrigger} />
|
||||
<section
|
||||
aria-label={t(($) => $['mainNav.marketplace'], { ns: 'common' })}
|
||||
className={cn(
|
||||
'w-full shrink-0 bg-background-default',
|
||||
styles.catalogNavigation,
|
||||
isPinned && styles.catalogNavigationPinned,
|
||||
)}
|
||||
>
|
||||
<div className="w-full">
|
||||
<div className={cn('-ml-2', isPinned && styles.catalogTabsPinned)}>{catalogTabs}</div>
|
||||
{catalogCategories ? (
|
||||
<div className={cn('mt-4', isPinned && styles.categoriesPinned)}>
|
||||
{catalogCategories}
|
||||
</div>
|
||||
) : (
|
||||
<PluginTypeSwitch
|
||||
className={cn('mt-4', isPinned && styles.categoriesPinned)}
|
||||
variant="home"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default HomeCatalogNavigation
|
||||
@@ -0,0 +1,64 @@
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useTranslation } from '#i18n'
|
||||
import Link from '@/next/link'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
|
||||
export type HomeCatalogTab = 'plugins' | 'templates'
|
||||
|
||||
type HomeCatalogTabsProps = {
|
||||
activeTab?: HomeCatalogTab
|
||||
className?: string
|
||||
isMarketplacePlatform: boolean
|
||||
}
|
||||
|
||||
const HomeCatalogTabs = ({
|
||||
activeTab = 'plugins',
|
||||
className,
|
||||
isMarketplacePlatform,
|
||||
}: HomeCatalogTabsProps) => {
|
||||
const { t } = useTranslation()
|
||||
const pluginsHref = isMarketplacePlatform ? '/plugins' : getMarketplaceUrl('/plugins')
|
||||
const templatesHref = isMarketplacePlatform ? '/templates' : getMarketplaceUrl('/templates')
|
||||
const isPluginsActive = activeTab === 'plugins'
|
||||
const isTemplatesActive = activeTab === 'templates'
|
||||
const pluginsLabel = t(($) => $['marketplace.home.plugins'], { ns: 'plugin' })
|
||||
const templatesLabel = t(($) => $['marketplace.home.templates'], { ns: 'plugin' })
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label={t(($) => $['mainNav.marketplace'], { ns: 'common' })}
|
||||
className={cn('flex h-8 items-center gap-1', className)}
|
||||
>
|
||||
<Link
|
||||
href={pluginsHref}
|
||||
aria-label={pluginsLabel}
|
||||
aria-current={isPluginsActive ? 'page' : undefined}
|
||||
className={cn(
|
||||
'relative flex h-8 cursor-pointer items-start rounded-lg px-[9px] pt-2 outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid',
|
||||
isPluginsActive ? 'body-sm-medium' : 'body-sm-regular',
|
||||
isPluginsActive
|
||||
? 'bg-state-base-active text-text-primary'
|
||||
: 'text-text-tertiary hover:bg-state-base-hover',
|
||||
)}
|
||||
>
|
||||
{pluginsLabel}
|
||||
</Link>
|
||||
<Link
|
||||
href={templatesHref}
|
||||
aria-label={templatesLabel}
|
||||
aria-current={isTemplatesActive ? 'page' : undefined}
|
||||
className={cn(
|
||||
'relative flex h-8 cursor-pointer items-center rounded-[10px] p-2 outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid',
|
||||
isTemplatesActive ? 'body-sm-medium' : 'body-sm-regular',
|
||||
isTemplatesActive
|
||||
? 'bg-state-base-active text-text-primary'
|
||||
: 'text-text-tertiary hover:bg-state-base-hover',
|
||||
)}
|
||||
>
|
||||
{templatesLabel}
|
||||
</Link>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
export default HomeCatalogTabs
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { HomeCatalogTab } from './home-catalog-tabs'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useTranslation } from '#i18n'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
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 { HomeStickyCatalogTabs } from './home-sticky-state-provider'
|
||||
import styles from './home-sticky.module.css'
|
||||
|
||||
type HomeHeaderProps = {
|
||||
activeTab?: HomeCatalogTab
|
||||
actions?: React.ReactNode
|
||||
isMarketplacePlatform: boolean
|
||||
}
|
||||
|
||||
function Guide() {
|
||||
const docLink = useDocLink()
|
||||
const { t } = useTranslation('plugin')
|
||||
|
||||
return (
|
||||
<Link href={docLink()} target="_blank" rel="noopener noreferrer" className={styles.guide}>
|
||||
<Button variant="ghost" size="large" className="min-w-[94px] gap-0.5 px-3 text-text-primary">
|
||||
<span aria-hidden className="i-ri-map-2-line size-5" />
|
||||
<span className="px-1 system-md-medium">{t(($) => $['marketplace.home.guide'])}</span>
|
||||
</Button>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
const HomeHeader = ({ activeTab = 'plugins', actions, isMarketplacePlatform }: HomeHeaderProps) => {
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
'sticky top-0 z-50 flex w-full shrink-0 items-center gap-4 border-b border-divider-regular bg-background-default px-4 py-1.5 md:px-9',
|
||||
styles.header,
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-4">
|
||||
<Link
|
||||
href="/"
|
||||
aria-label="Dify Marketplace"
|
||||
className="flex h-full w-[141.933px] shrink-0 items-center"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'h-[16.386px] w-[141.761px] max-w-none shrink-0',
|
||||
styles.marketplaceLogoLight,
|
||||
)}
|
||||
height="16.386"
|
||||
src={MarketplaceLogo.src}
|
||||
width="141.761"
|
||||
/>
|
||||
<img
|
||||
alt=""
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'h-[16.386px] w-[141.761px] max-w-none shrink-0',
|
||||
styles.marketplaceLogoDark,
|
||||
)}
|
||||
height="16.386"
|
||||
src={MarketplaceLogoDark.src}
|
||||
width="141.761"
|
||||
/>
|
||||
</Link>
|
||||
<HomeStickyCatalogTabs>
|
||||
<HomeCatalogTabs
|
||||
activeTab={activeTab}
|
||||
className={styles.headerCatalogTabs}
|
||||
isMarketplacePlatform={isMarketplacePlatform}
|
||||
/>
|
||||
</HomeStickyCatalogTabs>
|
||||
</div>
|
||||
|
||||
<div className="flex h-full min-w-0 flex-1 items-center justify-end gap-2.5">
|
||||
<Guide />
|
||||
{actions}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export default HomeHeader
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useTranslation } from '#i18n'
|
||||
|
||||
type HomeHeroProps = {
|
||||
isMarketplacePlatform: boolean
|
||||
subtitle?: ReactNode
|
||||
title?: ReactNode
|
||||
}
|
||||
|
||||
const HomeHero = ({ isMarketplacePlatform, subtitle, title }: HomeHeroProps) => {
|
||||
const { t } = useTranslation('plugin')
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
'relative flex shrink-0 justify-center bg-background-default px-4',
|
||||
!isMarketplacePlatform && 'pt-6',
|
||||
)}
|
||||
>
|
||||
<div className="relative flex h-[162px] w-full max-w-[726px] flex-col items-center pt-[41px]">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<h1
|
||||
className="text-[28px] leading-[1.2] font-medium tracking-[-0.56px] text-text-primary"
|
||||
style={{ fontFamily: "var(--font-family-brand, 'Söhne', var(--font-sans))" }}
|
||||
>
|
||||
{title ?? t(($) => $['marketplace.home.heroTitle'])}
|
||||
</h1>
|
||||
<p className="text-[13px] leading-4 font-light tracking-[-0.065px] text-text-tertiary">
|
||||
{subtitle ?? t(($) => $['marketplace.home.heroSubtitle'])}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default HomeHero
|
||||
@@ -0,0 +1,50 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useTranslation } from '#i18n'
|
||||
import SearchBoxWrapper from '@/app/components/plugins/marketplace/search-box/search-box-wrapper'
|
||||
import styles from './home-sticky.module.css'
|
||||
|
||||
const HomeSearch = ({ children }: { children?: ReactNode }) => {
|
||||
const searchRef = useRef<HTMLDivElement>(null)
|
||||
const { t } = useTranslation('plugin')
|
||||
|
||||
useEffect(() => {
|
||||
const handleGlobalSearchShortcut = (event: KeyboardEvent) => {
|
||||
if (event.key.toLowerCase() !== 'k' || (!event.metaKey && !event.ctrlKey)) return
|
||||
|
||||
event.preventDefault()
|
||||
searchRef.current?.querySelector('input')?.focus()
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleGlobalSearchShortcut)
|
||||
return () => document.removeEventListener('keydown', handleGlobalSearchShortcut)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none sticky z-[60] -mt-9 flex h-9 shrink-0 justify-center px-4',
|
||||
styles.search,
|
||||
)}
|
||||
>
|
||||
<div ref={searchRef} className="pointer-events-auto relative w-full max-w-[420px]">
|
||||
{children ?? (
|
||||
<SearchBoxWrapper
|
||||
wrapperClassName="w-full max-w-none"
|
||||
inputClassName="h-9 w-full rounded-[10px] bg-components-input-bg-normal [&>div]:px-2.5"
|
||||
inputElementClassName="text-[14px] leading-5"
|
||||
searchIconName="i-ri-search-line"
|
||||
placeholder={t(($) => $['marketplace.home.searchPlaceholder'])}
|
||||
showTags={false}
|
||||
usedInMarketplace={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default HomeSearch
|
||||
@@ -0,0 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { ScopeProvider } from 'jotai-scope'
|
||||
import { homeCatalogPinnedAtom, homeStickyScopedAtoms } from './home-sticky-state'
|
||||
|
||||
export function HomeStickyStateProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ScopeProvider atoms={homeStickyScopedAtoms} name="MarketplaceHomeSticky">
|
||||
{children}
|
||||
</ScopeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function HomeStickyCatalogTabs({ children }: { children: ReactNode }) {
|
||||
const isCatalogPinned = useAtomValue(homeCatalogPinnedAtom)
|
||||
|
||||
return isCatalogPinned ? children : null
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { atom } from 'jotai'
|
||||
|
||||
export const homeCatalogPinnedAtom = atom(false)
|
||||
|
||||
export const homeStickyScopedAtoms = [homeCatalogPinnedAtom]
|
||||
@@ -0,0 +1,73 @@
|
||||
.header {
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.headerCatalogTabs {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.marketplaceLogoLight {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.marketplaceLogoDark {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .marketplaceLogoLight {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark']) .marketplaceLogoDark {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.search {
|
||||
top: 6px;
|
||||
}
|
||||
|
||||
.catalogNavigationTrigger {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
flex-shrink: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.catalogNavigation {
|
||||
position: sticky;
|
||||
top: 48px;
|
||||
z-index: 40;
|
||||
padding: 24px 36px 16px;
|
||||
}
|
||||
|
||||
.catalogNavigationPinned {
|
||||
border-bottom: 1px solid var(--color-divider-regular);
|
||||
padding-top: 16px;
|
||||
padding-bottom: 15px;
|
||||
background-color: var(--color-background-default);
|
||||
}
|
||||
|
||||
.catalogTabsPinned {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.categoriesPinned {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.catalogContent {
|
||||
min-height: calc(100vh - 112px);
|
||||
min-height: calc(100dvh - 112px);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.guide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.search {
|
||||
position: relative;
|
||||
top: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
.wrapper {
|
||||
padding-bottom: 30px;
|
||||
}
|
||||
|
||||
.copy {
|
||||
flex: none;
|
||||
width: 36.9167%;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.recommendVisual {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.recommendCards {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
padding: 42px 36px;
|
||||
}
|
||||
|
||||
.navigation {
|
||||
top: 208px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.contentTrack {
|
||||
transition: transform 400ms ease-out;
|
||||
}
|
||||
|
||||
.card {
|
||||
flex: 1 1 161px;
|
||||
width: auto;
|
||||
min-width: 161px;
|
||||
max-width: 210px;
|
||||
box-shadow: 0 8px 7.2px -6px rgb(0 0 0 / 19%);
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
|
||||
@container (max-width: 751px) {
|
||||
.recommendCards > .card:nth-child(n + 4) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 578px) {
|
||||
.recommendCards > .card:nth-child(n + 3) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 405px) {
|
||||
.recommendCards {
|
||||
justify-content: flex-start;
|
||||
overflow-x: auto;
|
||||
scroll-snap-type: x proximity;
|
||||
scrollbar-width: none;
|
||||
overscroll-behavior-x: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.recommendCards::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.recommendCards > .card:nth-child(n) {
|
||||
display: flex;
|
||||
flex: 0 0 161px;
|
||||
}
|
||||
}
|
||||
|
||||
.updatesArt {
|
||||
width: 33.3333%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.updatesDescription {
|
||||
display: -webkit-box;
|
||||
max-height: 40px;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.contentTrack {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1232px) {
|
||||
.marketplaceCopy {
|
||||
width: 443px;
|
||||
}
|
||||
|
||||
.updatesArt {
|
||||
width: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1260px) {
|
||||
.embeddedCopy {
|
||||
width: 431px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,609 @@
|
||||
'use client'
|
||||
|
||||
import type { RefObject } from 'react'
|
||||
import type {
|
||||
BannerAd,
|
||||
BannerBlog,
|
||||
BannerEvent,
|
||||
BannerRecommend,
|
||||
BannerRecommendCard,
|
||||
PluginBanner,
|
||||
} from './banners'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useCallback, useEffect, 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.jpg'
|
||||
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' | '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={3840}
|
||||
height={2160}
|
||||
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 opensInNewTab = /^https?:\/\//.test(banner.content.link)
|
||||
|
||||
return (
|
||||
<div className="flex h-[200px] w-full overflow-hidden rounded-2xl bg-background-body">
|
||||
<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>
|
||||
)}
|
||||
<Link
|
||||
href={banner.content.link}
|
||||
target={opensInNewTab ? '_blank' : undefined}
|
||||
rel={opensInNewTab ? 'noopener noreferrer' : undefined}
|
||||
aria-label={`Read more about ${banner.content.blog_title}`}
|
||||
className="flex shrink-0 items-center gap-1 text-[13px] leading-[normal] font-medium text-text-accent underline decoration-[10%] underline-offset-2"
|
||||
>
|
||||
<span>Read more</span>
|
||||
<span aria-hidden className="i-ri-arrow-right-s-line size-4" />
|
||||
</Link>
|
||||
</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')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
onSelect,
|
||||
onNext,
|
||||
}: {
|
||||
banners: PluginBanner[]
|
||||
selectedIndex: number
|
||||
carouselRootRef: RefObject<HTMLDivElement | null>
|
||||
onSelect: (index: number) => void
|
||||
onNext: () => void
|
||||
}) {
|
||||
const { t } = useTranslation('plugin')
|
||||
const progressRef = useRef<HTMLSpanElement>(null)
|
||||
const progressAnimationRef = useRef<Animation | null>(null)
|
||||
const pauseReasonsRef = useRef(new Set<AutoplayPauseReason>())
|
||||
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 progressAnimation = progressAnimationRef.current
|
||||
if (!progressAnimation) return
|
||||
|
||||
if (pauseReasonsRef.current.size > 0) progressAnimation.pause()
|
||||
else progressAnimation.play()
|
||||
}, [])
|
||||
|
||||
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)
|
||||
|
||||
return () => {
|
||||
carouselRoot.removeEventListener('mouseenter', handleMouseEnter)
|
||||
carouselRoot.removeEventListener('mouseleave', handleMouseLeave)
|
||||
carouselRoot.removeEventListener('focusin', handleFocusIn)
|
||||
carouselRoot.removeEventListener('focusout', handleFocusOut)
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
}
|
||||
}, [carouselRootRef, 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)
|
||||
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)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return
|
||||
event.preventDefault()
|
||||
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}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return
|
||||
event.preventDefault()
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeTrending({
|
||||
banners,
|
||||
isMarketplacePlatform,
|
||||
}: {
|
||||
banners: PluginBanner[]
|
||||
isMarketplacePlatform: boolean
|
||||
}) {
|
||||
const { t } = useTranslation('plugin')
|
||||
const carouselRootRef = useRef<HTMLDivElement>(null)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const selectSlide = useCallback((index: number) => setSelectedIndex(index), [])
|
||||
const selectNextSlide = useCallback(
|
||||
() => setSelectedIndex((currentIndex) => (currentIndex + 1) % banners.length),
|
||||
[banners.length],
|
||||
)
|
||||
|
||||
if (banners.length === 0) return null
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={t(($) => $['marketplace.home.trendingTitle'])}
|
||||
className={cn(
|
||||
'shrink-0 bg-background-default pb-6',
|
||||
isMarketplacePlatform ? 'px-4 min-[1232px]:px-0' : 'px-4 md:px-9',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
styles.wrapper,
|
||||
'mx-auto w-full',
|
||||
isMarketplacePlatform ? 'max-w-[1200px]' : 'max-w-[1188px]',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
aria-label={t(($) => $['marketplace.home.trendingTitle'])}
|
||||
className="relative h-[200px] w-full rounded-2xl"
|
||||
>
|
||||
<TrendingNavigation
|
||||
banners={banners}
|
||||
selectedIndex={selectedIndex}
|
||||
carouselRootRef={carouselRootRef}
|
||||
onSelect={selectSlide}
|
||||
onNext={selectNextSlide}
|
||||
/>
|
||||
<div ref={carouselRootRef} className="h-full overflow-hidden rounded-2xl">
|
||||
<div
|
||||
aria-live="polite"
|
||||
className={cn(styles.contentTrack, 'flex h-full')}
|
||||
style={{ transform: `translate3d(-${selectedIndex * 100}%, 0, 0)` }}
|
||||
>
|
||||
{banners.map((banner, index) => {
|
||||
const isActive = index === selectedIndex
|
||||
|
||||
return (
|
||||
<div
|
||||
key={banner.id}
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
aria-label={banner.title}
|
||||
aria-hidden={!isActive}
|
||||
inert={!isActive}
|
||||
className="h-full min-w-0 shrink-0 grow-0 basis-full"
|
||||
>
|
||||
<HomeBannerSlide
|
||||
banner={banner}
|
||||
isMarketplacePlatform={isMarketplacePlatform}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default HomeTrending
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { PluginBanner } from './banners'
|
||||
import ListWrapper from '../list/list-wrapper'
|
||||
import HomeCatalogNavigation from './home-catalog-navigation'
|
||||
import HomeCatalogTabs from './home-catalog-tabs'
|
||||
import HomeHeader from './home-header'
|
||||
import HomeHero from './home-hero'
|
||||
import HomeSearch from './home-search'
|
||||
import { HomeStickyStateProvider } from './home-sticky-state-provider'
|
||||
import styles from './home-sticky.module.css'
|
||||
import HomeTrending from './home-trending'
|
||||
|
||||
type MarketplaceHomeProps = {
|
||||
actions?: React.ReactNode
|
||||
banners: PluginBanner[]
|
||||
isMarketplacePlatform: boolean
|
||||
linkToMarketplaceDetail: boolean
|
||||
showInstallButton: boolean
|
||||
}
|
||||
|
||||
const MarketplaceHome = ({
|
||||
actions,
|
||||
banners,
|
||||
isMarketplacePlatform,
|
||||
linkToMarketplaceDetail,
|
||||
showInstallButton,
|
||||
}: MarketplaceHomeProps) => {
|
||||
return (
|
||||
<HomeStickyStateProvider>
|
||||
<div className="flex min-h-full w-full flex-col bg-background-default">
|
||||
<HomeHeader actions={actions} isMarketplacePlatform={isMarketplacePlatform} />
|
||||
<div className="relative flex w-full flex-col">
|
||||
<HomeHero isMarketplacePlatform={isMarketplacePlatform} />
|
||||
<HomeSearch />
|
||||
{banners.length > 0 && (
|
||||
<>
|
||||
<div aria-hidden="true" className="h-12 shrink-0" />
|
||||
<HomeTrending banners={banners} isMarketplacePlatform={isMarketplacePlatform} />
|
||||
</>
|
||||
)}
|
||||
<HomeCatalogNavigation
|
||||
catalogTabs={<HomeCatalogTabs isMarketplacePlatform={isMarketplacePlatform} />}
|
||||
/>
|
||||
<div className="contents [&>div]:bg-background-default!">
|
||||
<ListWrapper
|
||||
className={styles.catalogContent}
|
||||
showInstallButton={showInstallButton}
|
||||
linkToMarketplaceDetail={linkToMarketplaceDetail}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</HomeStickyStateProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default MarketplaceHome
|
||||
@@ -1,17 +1,13 @@
|
||||
import type { SearchParams } from 'nuqs'
|
||||
import { PluginInstallPermissionProviderGuard } from '@/app/components/plugins/install-plugin/components/plugin-install-permission-provider'
|
||||
import { TanstackQueryInitializer } from '@/context/query-client'
|
||||
import Description from './description'
|
||||
import type { PluginBanner } from './home/banners'
|
||||
import type { MarketplaceViewProps } from './view'
|
||||
import { getLocaleOnServer } from '@/i18n-config/server'
|
||||
import { fetchPluginBanners } from './home/banners'
|
||||
import { HydrateQueryClient } from './hydration-server'
|
||||
import ListWrapper from './list/list-wrapper'
|
||||
import StickySearchAndSwitchWrapper from './sticky-search-and-switch-wrapper'
|
||||
import { MarketplaceView } from './view'
|
||||
|
||||
type MarketplaceProps = {
|
||||
showInstallButton?: boolean
|
||||
linkToMarketplaceDetail?: boolean
|
||||
pluginTypeSwitchClassName?: string
|
||||
isMarketplacePlatform?: boolean
|
||||
marketplaceNav?: React.ReactNode
|
||||
type MarketplaceProps = Omit<MarketplaceViewProps, 'banners'> & {
|
||||
language?: string
|
||||
/**
|
||||
* Pass the search params from the request to prefetch data on the server.
|
||||
*/
|
||||
@@ -19,31 +15,27 @@ type MarketplaceProps = {
|
||||
}
|
||||
|
||||
const Marketplace = async ({
|
||||
showInstallButton = false,
|
||||
linkToMarketplaceDetail = false,
|
||||
pluginTypeSwitchClassName,
|
||||
isMarketplacePlatform = false,
|
||||
marketplaceNav,
|
||||
language,
|
||||
searchParams,
|
||||
variant = 'default',
|
||||
...viewProps
|
||||
}: MarketplaceProps) => {
|
||||
let trendingBanners: PluginBanner[] = []
|
||||
|
||||
if (variant === 'home') {
|
||||
const locale = language ?? (await getLocaleOnServer())
|
||||
|
||||
try {
|
||||
trendingBanners = await fetchPluginBanners(locale)
|
||||
} catch {
|
||||
// Keep the homepage available if Marketplace banner delivery is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<TanstackQueryInitializer>
|
||||
<HydrateQueryClient searchParams={searchParams}>
|
||||
<PluginInstallPermissionProviderGuard canInstallPlugin={showInstallButton}>
|
||||
<Description
|
||||
isMarketplacePlatform={isMarketplacePlatform}
|
||||
marketplaceNav={marketplaceNav}
|
||||
/>
|
||||
{!isMarketplacePlatform && (
|
||||
<StickySearchAndSwitchWrapper pluginTypeSwitchClassName={pluginTypeSwitchClassName} />
|
||||
)}
|
||||
<ListWrapper
|
||||
showInstallButton={showInstallButton}
|
||||
linkToMarketplaceDetail={linkToMarketplaceDetail}
|
||||
/>
|
||||
</PluginInstallPermissionProviderGuard>
|
||||
</HydrateQueryClient>
|
||||
</TanstackQueryInitializer>
|
||||
<HydrateQueryClient searchParams={searchParams}>
|
||||
<MarketplaceView {...viewProps} banners={trendingBanners} variant={variant} />
|
||||
</HydrateQueryClient>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -101,4 +101,14 @@ describe('ListWrapper', () => {
|
||||
|
||||
expect(screen.getAllByTestId('loading')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps the supplied layout constraint while category results are loading', () => {
|
||||
mockMarketplaceData.isLoading = true
|
||||
mockMarketplaceData.page = 1
|
||||
|
||||
const { container } = render(<ListWrapper className="catalog-content-min-height" />)
|
||||
|
||||
expect(container.firstElementChild).toHaveClass('catalog-content-min-height')
|
||||
expect(screen.getByTestId('loading')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
'use client'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useTranslation } from '#i18n'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import SortDropdown from '../sort-dropdown'
|
||||
@@ -6,10 +7,15 @@ import { useMarketplaceData } from '../state'
|
||||
import List from './index'
|
||||
|
||||
type ListWrapperProps = {
|
||||
className?: string
|
||||
showInstallButton?: boolean
|
||||
linkToMarketplaceDetail?: boolean
|
||||
}
|
||||
const ListWrapper = ({ showInstallButton, linkToMarketplaceDetail }: ListWrapperProps) => {
|
||||
const ListWrapper = ({
|
||||
className,
|
||||
showInstallButton,
|
||||
linkToMarketplaceDetail,
|
||||
}: ListWrapperProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const {
|
||||
@@ -28,7 +34,10 @@ const ListWrapper = ({ showInstallButton, linkToMarketplaceDetail }: ListWrapper
|
||||
scrollbarGutter: 'stable',
|
||||
paddingBottom: 'calc(0.5rem + var(--marketplace-header-collapse-offset, 0px))',
|
||||
}}
|
||||
className="relative flex grow flex-col bg-background-default-subtle px-8 py-2"
|
||||
className={cn(
|
||||
'relative flex grow flex-col bg-background-default-subtle px-8 py-2',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full grow flex-col">
|
||||
{plugins && (
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
.homeItem {
|
||||
transition:
|
||||
color 150ms ease,
|
||||
background-color 150ms ease;
|
||||
}
|
||||
|
||||
.homeItem:hover {
|
||||
color: var(--color-text-secondary);
|
||||
background-color: var(--color-state-base-hover);
|
||||
}
|
||||
|
||||
.homeItemActive {
|
||||
color: var(--color-saas-dify-blue-inverted);
|
||||
background-color: var(--color-background-interaction-from-bg-2);
|
||||
}
|
||||
|
||||
.homeItemActive:hover {
|
||||
background-color: var(--color-state-base-hover);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.homeItem {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,25 @@
|
||||
'use client'
|
||||
import type { ActivePluginType } from './constants'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
RiArchive2Line,
|
||||
RiBrain2Line,
|
||||
RiDatabase2Line,
|
||||
RiHammerLine,
|
||||
RiPuzzle2Line,
|
||||
RiSpeakAiLine,
|
||||
} from '@remixicon/react'
|
||||
import { useSetAtom } from 'jotai'
|
||||
import { Fragment } from 'react'
|
||||
import { useTranslation } from '#i18n'
|
||||
import { Trigger as TriggerIcon } from '@/app/components/base/icons/src/vender/plugin'
|
||||
import PluginIcon from '@/app/components/base/icons/src/vender/plugin/Plugin'
|
||||
import { searchModeAtom, useActivePluginType } from './atoms'
|
||||
import { PLUGIN_CATEGORY_WITH_COLLECTIONS, PLUGIN_TYPE_SEARCH_MAP } from './constants'
|
||||
import styles from './plugin-type-switch.module.css'
|
||||
|
||||
type PluginTypeSwitchProps = {
|
||||
className?: string
|
||||
variant?: 'default' | 'hero'
|
||||
variant?: 'default' | 'hero' | 'home'
|
||||
}
|
||||
const PluginTypeSwitch = ({ className, variant = 'default' }: PluginTypeSwitchProps) => {
|
||||
|
||||
function PluginTypeSwitch({ className, variant = 'default' }: PluginTypeSwitchProps) {
|
||||
const { t } = useTranslation()
|
||||
const [activePluginType, handleActivePluginTypeChange] = useActivePluginType()
|
||||
const setSearchMode = useSetAtom(searchModeAtom)
|
||||
const isHero = variant === 'hero'
|
||||
const isHome = variant === 'home'
|
||||
const iconClassName = 'mr-1.5 size-4'
|
||||
|
||||
const options: Array<{
|
||||
@@ -38,42 +32,51 @@ const PluginTypeSwitch = ({ className, variant = 'default' }: PluginTypeSwitchPr
|
||||
text: isHero
|
||||
? t(($) => $['marketplace.allPlugins'], { ns: 'plugin' })
|
||||
: t(($) => $['category.all'], { ns: 'plugin' }),
|
||||
icon: isHero ? <PluginIcon className={iconClassName} /> : null,
|
||||
icon: isHero || isHome ? <PluginIcon className={iconClassName} /> : null,
|
||||
},
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.model,
|
||||
text: t(($) => $['category.models'], { ns: 'plugin' }),
|
||||
icon: <RiBrain2Line className={iconClassName} />,
|
||||
icon: <span aria-hidden className={cn('i-ri-brain-2-line', iconClassName)} />,
|
||||
},
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.tool,
|
||||
text: t(($) => $['category.tools'], { ns: 'plugin' }),
|
||||
icon: <RiHammerLine className={iconClassName} />,
|
||||
icon: <span aria-hidden className={cn('i-ri-hammer-line', iconClassName)} />,
|
||||
},
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.datasource,
|
||||
text: t(($) => $['category.datasources'], { ns: 'plugin' }),
|
||||
icon: <RiDatabase2Line className={iconClassName} />,
|
||||
text: t(($) => $[isHome ? 'categorySingle.datasource' : 'category.datasources'], {
|
||||
ns: 'plugin',
|
||||
}),
|
||||
icon: <span aria-hidden className={cn('i-ri-database-2-line', iconClassName)} />,
|
||||
},
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.agent,
|
||||
text: t(($) => $['category.agents'], { ns: 'plugin' }),
|
||||
icon: <RiSpeakAiLine className={iconClassName} />,
|
||||
text: t(($) => $[isHome ? 'categorySingle.agent' : 'category.agents'], { ns: 'plugin' }),
|
||||
icon: (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn('i-custom-vender-integrations-agent-strategy', iconClassName)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.trigger,
|
||||
text: t(($) => $['category.triggers'], { ns: 'plugin' }),
|
||||
icon: <TriggerIcon className={iconClassName} />,
|
||||
icon: (
|
||||
<span aria-hidden className={cn('i-custom-vender-integrations-trigger', iconClassName)} />
|
||||
),
|
||||
},
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.extension,
|
||||
text: t(($) => $['category.extensions'], { ns: 'plugin' }),
|
||||
icon: <RiPuzzle2Line className={iconClassName} />,
|
||||
icon: <span aria-hidden className={cn('i-ri-puzzle-2-line', iconClassName)} />,
|
||||
},
|
||||
{
|
||||
value: PLUGIN_TYPE_SEARCH_MAP.bundle,
|
||||
text: t(($) => $['category.bundles'], { ns: 'plugin' }),
|
||||
icon: <RiArchive2Line className={iconClassName} />,
|
||||
icon: <span aria-hidden className={cn('i-ri-archive-2-line', iconClassName)} />,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -82,27 +85,39 @@ const PluginTypeSwitch = ({ className, variant = 'default' }: PluginTypeSwitchPr
|
||||
className={cn(
|
||||
isHero
|
||||
? 'flex shrink-0 items-center gap-1 overflow-x-auto'
|
||||
: 'flex shrink-0 items-center justify-center space-x-2 bg-background-body py-3',
|
||||
: isHome
|
||||
? 'flex w-full shrink-0 scrollbar-none items-center justify-start gap-1 overflow-x-auto'
|
||||
: 'flex shrink-0 items-center justify-center space-x-2 bg-background-body py-3',
|
||||
className,
|
||||
)}
|
||||
role="group"
|
||||
aria-label={t(($) => $['marketplace.allPlugins'], { ns: 'plugin' })}
|
||||
>
|
||||
{options.map((option, index) => {
|
||||
const isActive = activePluginType === option.value
|
||||
|
||||
return (
|
||||
<Fragment key={option.value}>
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={isActive}
|
||||
className={cn(
|
||||
'flex h-8 cursor-pointer items-center rounded-lg border border-transparent px-2.5 system-md-medium whitespace-nowrap',
|
||||
isHero ? 'text-text-primary-on-surface' : 'text-text-tertiary',
|
||||
'flex h-8 cursor-pointer items-center rounded-lg border border-transparent px-2.5 system-md-medium whitespace-nowrap outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid',
|
||||
isHero
|
||||
? 'text-text-primary-on-surface'
|
||||
: isHome
|
||||
? cn('min-w-12 shrink-0 justify-center text-text-tertiary', styles.homeItem)
|
||||
: 'text-text-tertiary',
|
||||
!isActive &&
|
||||
(isHero
|
||||
? 'hover:bg-white/20'
|
||||
: 'hover:bg-state-base-hover hover:text-text-secondary'),
|
||||
: !isHome && 'hover:bg-state-base-hover hover:text-text-secondary'),
|
||||
isActive &&
|
||||
(isHero
|
||||
? 'border-white/95 bg-components-main-nav-nav-button-bg-active text-saas-dify-blue-inverted shadow-md backdrop-blur-[5px]'
|
||||
: 'border-components-main-nav-nav-button-border bg-components-main-nav-nav-button-bg-active! text-components-main-nav-nav-button-text-active! shadow-xs'),
|
||||
: isHome
|
||||
? styles.homeItemActive
|
||||
: 'border-components-main-nav-nav-button-border bg-components-main-nav-nav-button-bg-active! text-components-main-nav-nav-button-text-active! shadow-xs'),
|
||||
)}
|
||||
onClick={() => {
|
||||
handleActivePluginTypeChange(option.value)
|
||||
@@ -113,7 +128,7 @@ const PluginTypeSwitch = ({ className, variant = 'default' }: PluginTypeSwitchPr
|
||||
>
|
||||
{option.icon}
|
||||
{option.text}
|
||||
</div>
|
||||
</button>
|
||||
{isHero && index === 0 && (
|
||||
<div
|
||||
aria-hidden
|
||||
|
||||
+2
-1
@@ -19,7 +19,7 @@ vi.mock('../index', () => ({
|
||||
|
||||
describe('SearchBoxWrapper', () => {
|
||||
it('passes marketplace search state into SearchBox', () => {
|
||||
render(<SearchBoxWrapper />)
|
||||
render(<SearchBoxWrapper searchIconName="i-ri-search-line" />)
|
||||
|
||||
expect(screen.getByTestId('search-box')).toBeInTheDocument()
|
||||
expect(mockSearchBox).toHaveBeenCalledWith(
|
||||
@@ -31,6 +31,7 @@ describe('SearchBoxWrapper', () => {
|
||||
tags: ['agent', 'rag'],
|
||||
onTagsChange: mockHandleFilterPluginTagsChange,
|
||||
placeholder: 'plugin.searchPlugins',
|
||||
searchIconName: 'i-ri-search-line',
|
||||
usedInMarketplace: true,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ type SearchBoxProps = {
|
||||
wrapperClassName?: string
|
||||
inputClassName?: string
|
||||
inputElementClassName?: string
|
||||
searchIconName?: string
|
||||
searchIconClassName?: string
|
||||
tags: string[]
|
||||
onTagsChange: (tags: string[]) => void
|
||||
@@ -31,6 +32,7 @@ function SearchBox({
|
||||
wrapperClassName,
|
||||
inputClassName,
|
||||
inputElementClassName,
|
||||
searchIconName = 'i-ri-search-line',
|
||||
searchIconClassName,
|
||||
tags,
|
||||
onTagsChange,
|
||||
@@ -111,7 +113,7 @@ function SearchBox({
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'i-ri-search-line',
|
||||
searchIconName,
|
||||
'size-4 text-components-input-text-placeholder',
|
||||
searchIconClassName,
|
||||
)}
|
||||
|
||||
@@ -8,6 +8,7 @@ type SearchBoxWrapperProps = {
|
||||
wrapperClassName?: string
|
||||
inputClassName?: string
|
||||
inputElementClassName?: string
|
||||
searchIconName?: string
|
||||
searchIconClassName?: string
|
||||
placeholder?: string
|
||||
showTags?: boolean
|
||||
@@ -18,6 +19,7 @@ const SearchBoxWrapper = ({
|
||||
wrapperClassName = 'z-11 mx-auto w-[640px] shrink-0',
|
||||
inputClassName = 'w-full',
|
||||
inputElementClassName,
|
||||
searchIconName,
|
||||
searchIconClassName,
|
||||
placeholder,
|
||||
showTags = true,
|
||||
@@ -32,6 +34,7 @@ const SearchBoxWrapper = ({
|
||||
wrapperClassName={wrapperClassName}
|
||||
inputClassName={inputClassName}
|
||||
inputElementClassName={inputElementClassName}
|
||||
searchIconName={searchIconName}
|
||||
searchIconClassName={searchIconClassName}
|
||||
search={searchPluginText}
|
||||
onSearchChange={handleSearchPluginTextChange}
|
||||
|
||||
@@ -8,7 +8,7 @@ export const marketplaceSearchParamsParsers = {
|
||||
Object.values(PLUGIN_TYPE_SEARCH_MAP) as ActivePluginType[],
|
||||
)
|
||||
.withDefault('all')
|
||||
.withOptions({ history: 'replace', clearOnDefault: false }),
|
||||
.withOptions({ history: 'replace', clearOnDefault: false, scroll: false }),
|
||||
q: parseAsString.withDefault('').withOptions({ history: 'replace' }),
|
||||
tags: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { PluginBanner } from './home/banners'
|
||||
import { PluginInstallPermissionProviderGuard } from '@/app/components/plugins/install-plugin/components/plugin-install-permission-provider'
|
||||
import Description from './description'
|
||||
import MarketplaceHome from './home'
|
||||
import ListWrapper from './list/list-wrapper'
|
||||
import StickySearchAndSwitchWrapper from './sticky-search-and-switch-wrapper'
|
||||
|
||||
export type MarketplaceVariant = 'default' | 'home'
|
||||
|
||||
export type MarketplaceViewProps = {
|
||||
banners: PluginBanner[]
|
||||
showInstallButton?: boolean
|
||||
linkToMarketplaceDetail?: boolean
|
||||
pluginTypeSwitchClassName?: string
|
||||
isMarketplacePlatform?: boolean
|
||||
marketplaceNav?: React.ReactNode
|
||||
variant?: MarketplaceVariant
|
||||
homeHeaderActions?: React.ReactNode
|
||||
}
|
||||
|
||||
export function MarketplaceView({
|
||||
banners,
|
||||
showInstallButton = false,
|
||||
linkToMarketplaceDetail = false,
|
||||
pluginTypeSwitchClassName,
|
||||
isMarketplacePlatform = false,
|
||||
marketplaceNav,
|
||||
variant = 'default',
|
||||
homeHeaderActions,
|
||||
}: MarketplaceViewProps) {
|
||||
return (
|
||||
<PluginInstallPermissionProviderGuard canInstallPlugin={showInstallButton}>
|
||||
{variant === 'home' ? (
|
||||
<MarketplaceHome
|
||||
actions={homeHeaderActions}
|
||||
banners={banners}
|
||||
isMarketplacePlatform={isMarketplacePlatform}
|
||||
linkToMarketplaceDetail={linkToMarketplaceDetail}
|
||||
showInstallButton={showInstallButton}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Description
|
||||
isMarketplacePlatform={isMarketplacePlatform}
|
||||
marketplaceNav={marketplaceNav}
|
||||
/>
|
||||
{!isMarketplacePlatform && (
|
||||
<StickySearchAndSwitchWrapper
|
||||
pluginTypeSwitchClassName={pluginTypeSwitchClassName}
|
||||
/>
|
||||
)}
|
||||
<ListWrapper
|
||||
showInstallButton={showInstallButton}
|
||||
linkToMarketplaceDetail={linkToMarketplaceDetail}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</PluginInstallPermissionProviderGuard>
|
||||
)
|
||||
}
|
||||
@@ -278,8 +278,8 @@ describe('PluginPage Component', () => {
|
||||
// The marketplace content should be visible when enable_marketplace is true and on discover tab
|
||||
const container = document.getElementById('marketplace-container')
|
||||
expect(container).toBeInTheDocument()
|
||||
// Check that marketplace-specific links are shown
|
||||
expect(screen.getByText(/requestAPlugin/i)).toBeInTheDocument()
|
||||
expect(screen.getByTestId('marketplace-content')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/requestAPlugin/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use plugins as document title on plugins tab', () => {
|
||||
@@ -330,13 +330,13 @@ describe('PluginPage Component', () => {
|
||||
expect(screen.getByTestId('plugins-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show marketplace links when on marketplace tab', () => {
|
||||
it('should let the marketplace homepage own its navigation', () => {
|
||||
vi.mocked(useQueryState).mockReturnValue(['discover', vi.fn()])
|
||||
|
||||
render(<PluginPageWithContext {...createDefaultProps()} />)
|
||||
// Check for marketplace-specific buttons
|
||||
expect(screen.getByText(/requestAPlugin/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/publishPlugins/i)).toBeInTheDocument()
|
||||
expect(screen.getByTestId('marketplace-content')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/requestAPlugin/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/publishPlugins/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show marketplace links when on plugins tab', () => {
|
||||
@@ -691,9 +691,9 @@ describe('PluginPage Component', () => {
|
||||
|
||||
render(<PluginPageWithContext {...createDefaultProps()} />)
|
||||
|
||||
// Should show marketplace links when on a plugin type tab
|
||||
expect(screen.getByText(/requestAPlugin/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/publishPlugins/i)).toBeInTheDocument()
|
||||
expect(screen.getByTestId('marketplace-content')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/requestAPlugin/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/publishPlugins/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render marketplace content when isExploringMarketplace and enable_marketplace are true', () => {
|
||||
@@ -1157,11 +1157,11 @@ describe('PluginPage Integration', () => {
|
||||
|
||||
const { rerender } = render(<PluginPageWithContext {...createDefaultProps()} />)
|
||||
|
||||
// With enable_marketplace: true (default mock), marketplace links should show
|
||||
expect(screen.getByText(/requestAPlugin/i)).toBeInTheDocument()
|
||||
// With enable_marketplace: true (default mock), marketplace content should show
|
||||
expect(screen.getByTestId('marketplace-content')).toBeInTheDocument()
|
||||
|
||||
// Rerender to verify consistent behavior
|
||||
rerender(<PluginPageWithContext {...createDefaultProps()} />)
|
||||
expect(screen.getByText(/publishPlugins/i)).toBeInTheDocument()
|
||||
expect(screen.getByTestId('marketplace-content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { PluginPageTab } from './context'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { RiBookOpenLine, RiBugLine, RiDragDropLine, RiEqualizer2Line } from '@remixicon/react'
|
||||
import { RiBugLine, RiDragDropLine, RiEqualizer2Line } from '@remixicon/react'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
@@ -13,10 +13,8 @@ import { useTranslation } from 'react-i18next'
|
||||
import TabSlider from '@/app/components/base/tab-slider'
|
||||
import ReferenceSettingModal from '@/app/components/plugins/reference-setting-modal'
|
||||
import { SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS } from '@/config'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import Link from '@/next/link'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { PLUGIN_PAGE_TABS_MAP } from '../hooks'
|
||||
import { PluginInstallPermissionProvider } from '../install-plugin/components/plugin-install-permission-provider'
|
||||
@@ -77,7 +75,6 @@ type PluginPanelPermissionProps = {
|
||||
}
|
||||
const PluginPage = ({ plugins, marketplace }: PluginPageProps) => {
|
||||
const { t } = useTranslation()
|
||||
const docLink = useDocLink()
|
||||
const { replace } = useRouter()
|
||||
|
||||
const {
|
||||
@@ -173,85 +170,58 @@ const PluginPage = ({ plugins, marketplace }: PluginPageProps) => {
|
||||
isPluginsTab ? 'rounded-t-xl bg-components-panel-bg' : 'bg-background-body',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'sticky top-0 z-10 flex min-h-[60px] items-center gap-1 self-stretch bg-components-panel-bg px-12 pt-4 pb-2',
|
||||
isExploringMarketplace && 'bg-background-body',
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<TabSlider
|
||||
value={isPluginsTab ? PLUGIN_PAGE_TABS_MAP.plugins : PLUGIN_PAGE_TABS_MAP.marketplace}
|
||||
onChange={(nextTab) => {
|
||||
if (isPluginPageTab(nextTab)) setActiveTab(nextTab)
|
||||
}}
|
||||
options={options}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{isExploringMarketplace && (
|
||||
<>
|
||||
<Link
|
||||
href="https://github.com/langgenius/dify-plugins/issues/new?template=plugin_request.yaml"
|
||||
target="_blank"
|
||||
>
|
||||
<Button variant="ghost" className="text-text-tertiary">
|
||||
{t(($) => $.requestAPlugin, { ns: 'plugin' })}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link
|
||||
href={docLink(
|
||||
'/develop-plugin/publishing/marketplace-listing/release-to-dify-marketplace',
|
||||
)}
|
||||
target="_blank"
|
||||
>
|
||||
<Button className="px-3" variant="secondary-accent">
|
||||
<RiBookOpenLine className="mr-1 size-4" />
|
||||
{t(($) => $.publishPlugins, { ns: 'plugin' })}
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="mx-1 h-3.5 w-px shrink-0 bg-divider-regular"></div>
|
||||
</>
|
||||
)}
|
||||
<PluginTasks />
|
||||
{(canInstallPlugin || isPermissionLoading) && (
|
||||
<InstallPluginDropdown
|
||||
disabled={isPermissionLoading || !canInstallPlugin}
|
||||
onSwitchToMarketplaceTab={() => setActiveTab('discover')}
|
||||
{isPluginsTab && (
|
||||
<div className="sticky top-0 z-10 flex min-h-[60px] items-center gap-1 self-stretch bg-components-panel-bg px-12 pt-4 pb-2">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<TabSlider
|
||||
value={PLUGIN_PAGE_TABS_MAP.plugins}
|
||||
onChange={(nextTab) => {
|
||||
if (isPluginPageTab(nextTab)) setActiveTab(nextTab)
|
||||
}}
|
||||
options={options}
|
||||
/>
|
||||
)}
|
||||
{canDebugger && <DebugInfo />}
|
||||
{isPermissionLoading && (
|
||||
<Button
|
||||
className="h-full w-full p-2 text-components-button-secondary-text"
|
||||
disabled
|
||||
loading
|
||||
>
|
||||
<RiBugLine className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{(canSetPermissions || canSetPluginPreferences) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label={t(($) => $['privilege.title'], { ns: 'plugin' })}
|
||||
className="group size-full p-2 text-components-button-secondary-text"
|
||||
disabled={isReferenceSettingLoading || !referenceSetting}
|
||||
loading={isReferenceSettingLoading}
|
||||
onClick={setShowPluginSettingModal}
|
||||
>
|
||||
<RiEqualizer2Line className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<PluginTasks />
|
||||
{(canInstallPlugin || isPermissionLoading) && (
|
||||
<InstallPluginDropdown
|
||||
disabled={isPermissionLoading || !canInstallPlugin}
|
||||
onSwitchToMarketplaceTab={() => setActiveTab('discover')}
|
||||
/>
|
||||
<TooltipContent>{t(($) => $['privilege.title'], { ns: 'plugin' })}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
)}
|
||||
{canDebugger && <DebugInfo />}
|
||||
{isPermissionLoading && (
|
||||
<Button
|
||||
className="h-full w-full p-2 text-components-button-secondary-text"
|
||||
disabled
|
||||
loading
|
||||
>
|
||||
<RiBugLine className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{(canSetPermissions || canSetPluginPreferences) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label={t(($) => $['privilege.title'], { ns: 'plugin' })}
|
||||
className="group size-full p-2 text-components-button-secondary-text"
|
||||
disabled={isReferenceSettingLoading || !referenceSetting}
|
||||
loading={isReferenceSettingLoading}
|
||||
onClick={setShowPluginSettingModal}
|
||||
>
|
||||
<RiEqualizer2Line className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>{t(($) => $['privilege.title'], { ns: 'plugin' })}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isPluginsTab && (
|
||||
<>
|
||||
<PluginInstallPermissionProvider
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
@source '../../app';
|
||||
@source '../../context';
|
||||
@source '../../hooks';
|
||||
@source '../../../../app';
|
||||
@source '../../../packages/dify-ui/src';
|
||||
@source '../../node_modules/streamdown/dist';
|
||||
@source '../../node_modules/@streamdown/math/dist';
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
/* ---------- JS plugins ------------------------------------------------ */
|
||||
@plugin './plugins/icons.ts';
|
||||
@plugin './plugins/typography.ts';
|
||||
@source inline('i-ri-pause-large-fill');
|
||||
@source inline('i-ri-play-large-fill');
|
||||
|
||||
/* ---------- Project-only theme tokens --------------------------------- */
|
||||
@theme {
|
||||
|
||||
@@ -226,6 +226,18 @@
|
||||
"marketplace.difyMarketplace": "سوق Dify",
|
||||
"marketplace.discover": "اكتشف",
|
||||
"marketplace.empower": "تمكين تطوير الذكاء الاصطناعي الخاص بك",
|
||||
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
|
||||
"marketplace.home.guide": "دليل",
|
||||
"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.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.moreFrom": "المزيد من السوق",
|
||||
"marketplace.noPluginFound": "لم يتم العثور على إضافة",
|
||||
"marketplace.partnerTip": "تم التحقق بواسطة شريك Dify",
|
||||
|
||||
@@ -226,6 +226,18 @@
|
||||
"marketplace.difyMarketplace": "Dify Marktplatz",
|
||||
"marketplace.discover": "Entdecken",
|
||||
"marketplace.empower": "Unterstützen Sie Ihre KI-Entwicklung",
|
||||
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
|
||||
"marketplace.home.guide": "Leitfaden",
|
||||
"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.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.moreFrom": "Mehr aus dem Marketplace",
|
||||
"marketplace.noPluginFound": "Kein Plugin gefunden",
|
||||
"marketplace.partnerTip": "Von einem Dify-Partner verifiziert",
|
||||
|
||||
@@ -226,6 +226,21 @@
|
||||
"marketplace.difyMarketplace": "Dify Marketplace",
|
||||
"marketplace.discover": "Discover",
|
||||
"marketplace.empower": "Empower your AI development",
|
||||
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
|
||||
"marketplace.home.guide": "Guide",
|
||||
"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.trendingPause": "Pause",
|
||||
"marketplace.home.trendingPaginationLabel": "Trending pages",
|
||||
"marketplace.home.trendingPlay": "Play",
|
||||
"marketplace.home.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.home.trendingView": "View",
|
||||
"marketplace.moreFrom": "More from Marketplace",
|
||||
"marketplace.noPluginFound": "No integration found",
|
||||
"marketplace.partnerTip": "Verified by a Dify partner",
|
||||
|
||||
@@ -226,6 +226,18 @@
|
||||
"marketplace.difyMarketplace": "Mercado de Dify",
|
||||
"marketplace.discover": "Descubrir",
|
||||
"marketplace.empower": "Potencie su desarrollo de IA",
|
||||
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
|
||||
"marketplace.home.guide": "Guía",
|
||||
"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.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.moreFrom": "Más de Marketplace",
|
||||
"marketplace.noPluginFound": "No se ha encontrado ninguna integración",
|
||||
"marketplace.partnerTip": "Verificado por un socio de Dify",
|
||||
|
||||
@@ -226,6 +226,18 @@
|
||||
"marketplace.difyMarketplace": "بازار دیفی",
|
||||
"marketplace.discover": "کشف",
|
||||
"marketplace.empower": "توسعه هوش مصنوعی خود را توانمند کنید",
|
||||
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
|
||||
"marketplace.home.guide": "راهنما",
|
||||
"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.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.moreFrom": "اطلاعات بیشتر از Marketplace",
|
||||
"marketplace.noPluginFound": "هیچ افزونهای یافت نشد",
|
||||
"marketplace.partnerTip": "تأیید شده توسط یک شریک دیفی",
|
||||
|
||||
@@ -226,6 +226,18 @@
|
||||
"marketplace.difyMarketplace": "Marché Dify",
|
||||
"marketplace.discover": "Découvrir",
|
||||
"marketplace.empower": "Renforcez le développement de votre IA",
|
||||
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
|
||||
"marketplace.home.guide": "Guide",
|
||||
"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.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.moreFrom": "Plus de Marketplace",
|
||||
"marketplace.noPluginFound": "Aucune intégration trouvée",
|
||||
"marketplace.partnerTip": "Vérifié par un partenaire Dify",
|
||||
|
||||
@@ -226,6 +226,18 @@
|
||||
"marketplace.difyMarketplace": "डिफाई मार्केटप्लेस",
|
||||
"marketplace.discover": "खोजें",
|
||||
"marketplace.empower": "अपने एआई विकास को सशक्त बनाएं",
|
||||
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
|
||||
"marketplace.home.guide": "मार्गदर्शिका",
|
||||
"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.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.moreFrom": "मार्केटप्लेस से अधिक",
|
||||
"marketplace.noPluginFound": "कोई इंटीग्रेशन नहीं मिला",
|
||||
"marketplace.partnerTip": "Dify भागीदार द्वारा सत्यापित",
|
||||
|
||||
@@ -226,6 +226,18 @@
|
||||
"marketplace.difyMarketplace": "Dify Marketplace",
|
||||
"marketplace.discover": "Menemukan",
|
||||
"marketplace.empower": "Berdayakan pengembangan AI Anda",
|
||||
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
|
||||
"marketplace.home.guide": "Panduan",
|
||||
"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.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.moreFrom": "Selengkapnya dari Marketplace",
|
||||
"marketplace.noPluginFound": "Tidak ada integrasi yang ditemukan",
|
||||
"marketplace.partnerTip": "Diverifikasi oleh partner Dify",
|
||||
|
||||
@@ -226,6 +226,18 @@
|
||||
"marketplace.difyMarketplace": "Mercato Dify",
|
||||
"marketplace.discover": "Scoprire",
|
||||
"marketplace.empower": "Potenzia lo sviluppo dell'intelligenza artificiale",
|
||||
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
|
||||
"marketplace.home.guide": "Guida",
|
||||
"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.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.moreFrom": "Altro da Marketplace",
|
||||
"marketplace.noPluginFound": "Nessuna integrazione trovata",
|
||||
"marketplace.partnerTip": "Verificato da un partner Dify",
|
||||
|
||||
@@ -226,6 +226,18 @@
|
||||
"marketplace.difyMarketplace": "Dify マーケットプレイス",
|
||||
"marketplace.discover": "探索",
|
||||
"marketplace.empower": "AI 開発をサポートする",
|
||||
"marketplace.home.heroSubtitle": "Dify Marketplace で、より安全で信頼性の高いプラグインを見つけましょう。",
|
||||
"marketplace.home.guide": "ガイド",
|
||||
"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.trendingTitle": "みんながインストールしているプラグイン",
|
||||
"marketplace.moreFrom": "マーケットプレイスからのさらなる情報",
|
||||
"marketplace.noPluginFound": "インテグレーションが見つかりません",
|
||||
"marketplace.partnerTip": "このプラグインは Dify のパートナーによって認証されています",
|
||||
|
||||
@@ -226,6 +226,18 @@
|
||||
"marketplace.difyMarketplace": "Dify 마켓플레이스",
|
||||
"marketplace.discover": "발견하다",
|
||||
"marketplace.empower": "AI 개발 역량 강화",
|
||||
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
|
||||
"marketplace.home.guide": "가이드",
|
||||
"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.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.moreFrom": "Marketplace 에서 더 보기",
|
||||
"marketplace.noPluginFound": "플러그인을 찾을 수 없습니다.",
|
||||
"marketplace.partnerTip": "Dify 파트너에 의해 확인됨",
|
||||
|
||||
@@ -226,6 +226,18 @@
|
||||
"marketplace.difyMarketplace": "Dify Marketplace",
|
||||
"marketplace.discover": "Discover",
|
||||
"marketplace.empower": "Empower your AI development",
|
||||
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
|
||||
"marketplace.home.guide": "Handleiding",
|
||||
"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.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.moreFrom": "More from Marketplace",
|
||||
"marketplace.noPluginFound": "Geen plugin gevonden",
|
||||
"marketplace.partnerTip": "Verified by a Dify partner",
|
||||
|
||||
@@ -226,6 +226,18 @@
|
||||
"marketplace.difyMarketplace": "Rynek Dify",
|
||||
"marketplace.discover": "Odkryć",
|
||||
"marketplace.empower": "Zwiększ możliwości rozwoju sztucznej inteligencji",
|
||||
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
|
||||
"marketplace.home.guide": "Przewodnik",
|
||||
"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.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.moreFrom": "Więcej z Marketplace",
|
||||
"marketplace.noPluginFound": "Nie znaleziono integracji",
|
||||
"marketplace.partnerTip": "Zweryfikowane przez partnera Dify",
|
||||
|
||||
@@ -226,6 +226,18 @@
|
||||
"marketplace.difyMarketplace": "Mercado Dify",
|
||||
"marketplace.discover": "Descobrir",
|
||||
"marketplace.empower": "Capacite seu desenvolvimento de IA",
|
||||
"marketplace.home.heroSubtitle": "Crie com plugins mais seguros e confiáveis do Dify Marketplace.",
|
||||
"marketplace.home.guide": "Guia",
|
||||
"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.trendingTitle": "Os plugins que todos estão instalando",
|
||||
"marketplace.moreFrom": "Mais do Marketplace",
|
||||
"marketplace.noPluginFound": "Nenhuma integração encontrada",
|
||||
"marketplace.partnerTip": "Verificado por um parceiro da Dify",
|
||||
|
||||
@@ -226,6 +226,18 @@
|
||||
"marketplace.difyMarketplace": "Piața Dify",
|
||||
"marketplace.discover": "Descoperi",
|
||||
"marketplace.empower": "Îmbunătățește-ți dezvoltarea AI",
|
||||
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
|
||||
"marketplace.home.guide": "Ghid",
|
||||
"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.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.moreFrom": "Mai multe din Marketplace",
|
||||
"marketplace.noPluginFound": "Nu s-a găsit niciun plugin",
|
||||
"marketplace.partnerTip": "Verificat de un partener Dify",
|
||||
|
||||
@@ -226,6 +226,18 @@
|
||||
"marketplace.difyMarketplace": "Торговая площадка Dify",
|
||||
"marketplace.discover": "Обнаруживать",
|
||||
"marketplace.empower": "Расширьте возможности разработки ИИ",
|
||||
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
|
||||
"marketplace.home.guide": "Руководство",
|
||||
"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.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.moreFrom": "Больше из Marketplace",
|
||||
"marketplace.noPluginFound": "Плагин не найден",
|
||||
"marketplace.partnerTip": "Подтверждено партнером Dify",
|
||||
|
||||
@@ -226,6 +226,18 @@
|
||||
"marketplace.difyMarketplace": "Dify Marketplace",
|
||||
"marketplace.discover": "Odkrijte",
|
||||
"marketplace.empower": "Okrepite svoj razvoj AI",
|
||||
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
|
||||
"marketplace.home.guide": "Vodnik",
|
||||
"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.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.moreFrom": "Več iz tržnice",
|
||||
"marketplace.noPluginFound": "Nobenega vtičnika ni bilo najti.",
|
||||
"marketplace.partnerTip": "Potrjeno s strani partnerja Dify",
|
||||
|
||||
@@ -226,6 +226,18 @@
|
||||
"marketplace.difyMarketplace": "ตลาด Dify",
|
||||
"marketplace.discover": "ค้นพบ",
|
||||
"marketplace.empower": "เพิ่มศักยภาพในการพัฒนา AI ของคุณ",
|
||||
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
|
||||
"marketplace.home.guide": "คู่มือ",
|
||||
"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.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.moreFrom": "แอปเพิ่มเติมจาก Marketplace",
|
||||
"marketplace.noPluginFound": "ไม่พบปลั๊กอิน",
|
||||
"marketplace.partnerTip": "ได้รับการตรวจสอบโดยพันธมิตรของ Dify",
|
||||
|
||||
@@ -226,6 +226,18 @@
|
||||
"marketplace.difyMarketplace": "Dify Pazar Yeri",
|
||||
"marketplace.discover": "Keşfet",
|
||||
"marketplace.empower": "Yapay zeka geliştirmenizi güçlendirin",
|
||||
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
|
||||
"marketplace.home.guide": "Kılavuz",
|
||||
"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.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.moreFrom": "Pazar Yeri'nden daha fazlası",
|
||||
"marketplace.noPluginFound": "Eklenti bulunamadı",
|
||||
"marketplace.partnerTip": "Dify partner'ı tarafından doğrulandı",
|
||||
|
||||
@@ -226,6 +226,18 @@
|
||||
"marketplace.difyMarketplace": "Dify Marketplace",
|
||||
"marketplace.discover": "Виявити",
|
||||
"marketplace.empower": "Розширюйте можливості розробки штучного інтелекту",
|
||||
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
|
||||
"marketplace.home.guide": "Посібник",
|
||||
"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.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.moreFrom": "Більше від Marketplace",
|
||||
"marketplace.noPluginFound": "Плагін не знайдено",
|
||||
"marketplace.partnerTip": "Перевірено партнером Dify",
|
||||
|
||||
@@ -226,6 +226,18 @@
|
||||
"marketplace.difyMarketplace": "Thị trường Dify",
|
||||
"marketplace.discover": "Khám phá",
|
||||
"marketplace.empower": "Hỗ trợ phát triển AI của bạn",
|
||||
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
|
||||
"marketplace.home.guide": "Hướng dẫn",
|
||||
"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.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.moreFrom": "Các ứng dụng khác từ Marketplace",
|
||||
"marketplace.noPluginFound": "Không tìm thấy plugin nào",
|
||||
"marketplace.partnerTip": "Được xác nhận bởi một đối tác của Dify",
|
||||
|
||||
@@ -226,6 +226,21 @@
|
||||
"marketplace.difyMarketplace": "Dify Marketplace",
|
||||
"marketplace.discover": "探索",
|
||||
"marketplace.empower": "助力您的 AI 开发",
|
||||
"marketplace.home.heroSubtitle": "在 Dify Marketplace 探索更安全、更可靠的插件。",
|
||||
"marketplace.home.guide": "指南",
|
||||
"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.trendingPause": "暂停",
|
||||
"marketplace.home.trendingPaginationLabel": "热门推荐页码",
|
||||
"marketplace.home.trendingPlay": "播放",
|
||||
"marketplace.home.trendingTitle": "大家都在安装的插件",
|
||||
"marketplace.home.trendingView": "查看",
|
||||
"marketplace.moreFrom": "来自 Marketplace 的更多内容",
|
||||
"marketplace.noPluginFound": "未找到集成",
|
||||
"marketplace.partnerTip": "此插件由 Dify 合作伙伴认证",
|
||||
|
||||
@@ -82,19 +82,19 @@
|
||||
"autoUpdate.upgradeMode.partial": "僅選擇",
|
||||
"autoUpdate.upgradeModePlaceholder.exclude": "選定的插件將不會自動更新",
|
||||
"autoUpdate.upgradeModePlaceholder.partial": "只有選定的插件會自動更新。目前未選定任何插件,因此不會自動更新任何插件。",
|
||||
"category.agents": "代理策略",
|
||||
"category.all": "都",
|
||||
"category.bundles": "束",
|
||||
"category.agents": "Agent 策略",
|
||||
"category.all": "全部",
|
||||
"category.bundles": "整合包",
|
||||
"category.datasources": "資料來源",
|
||||
"category.extensions": "擴展",
|
||||
"category.extensions": "擴充功能",
|
||||
"category.models": "模型",
|
||||
"category.tools": "工具",
|
||||
"category.triggers": "觸發因素",
|
||||
"categorySingle.agent": "代理策略",
|
||||
"categorySingle.bundle": "捆",
|
||||
"category.triggers": "觸發器",
|
||||
"categorySingle.agent": "Agent 策略",
|
||||
"categorySingle.bundle": "整合包",
|
||||
"categorySingle.datasource": "資料來源",
|
||||
"categorySingle.extension": "外延",
|
||||
"categorySingle.model": "型",
|
||||
"categorySingle.extension": "擴充功能",
|
||||
"categorySingle.model": "模型",
|
||||
"categorySingle.tool": "工具",
|
||||
"categorySingle.trigger": "觸發器",
|
||||
"clearSearch": "清空{{label}}",
|
||||
@@ -226,6 +226,21 @@
|
||||
"marketplace.difyMarketplace": "Dify Marketplace",
|
||||
"marketplace.discover": "發現",
|
||||
"marketplace.empower": "為您的 AI 開發提供支援",
|
||||
"marketplace.home.heroSubtitle": "在 Dify Marketplace 探索更安全、更可靠的外掛程式。",
|
||||
"marketplace.home.guide": "指南",
|
||||
"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.trendingPause": "暫停",
|
||||
"marketplace.home.trendingPaginationLabel": "熱門推薦頁碼",
|
||||
"marketplace.home.trendingPlay": "播放",
|
||||
"marketplace.home.trendingTitle": "大家都在安裝的外掛程式",
|
||||
"marketplace.home.trendingView": "查看",
|
||||
"marketplace.moreFrom": "來自 Marketplace 的更多內容",
|
||||
"marketplace.noPluginFound": "未找到集成",
|
||||
"marketplace.partnerTip": "由 Dify 合作夥伴驗證",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="191.301" height="22.1123" viewBox="0 0 191.301 22.1123" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Vector">
|
||||
<path d="M21.6204 4.16343C23.0105 4.16343 23.5238 3.31151 23.5238 2.26003C23.5238 1.20856 23.0095 0.356634 21.6204 0.356634C20.2312 0.356634 19.717 1.20856 19.717 2.26003C19.717 3.31151 20.2312 4.16343 21.6204 4.16343Z" fill="#E8E8E8"/>
|
||||
<path d="M28.2832 4.57117V5.79533H25.1556V8.51515H28.2832V15.3142H23.116V5.79629H16.3169V8.51611H20.1247V15.3152H15.6377V18.035H36.034V15.3152H31.2745V8.51611H36.034V5.79629H31.2745V3.07646H36.034V0.356634H32.4987C30.1741 0.356634 28.2832 2.2466 28.2832 4.57117Z" fill="#E8E8E8"/>
|
||||
<path d="M5.77927 0.35564H0V18.0321H5.77927C12.918 18.0321 14.9576 13.9529 14.9576 9.1934C14.9576 4.43394 12.918 0.35564 5.77927 0.35564ZM5.84739 15.3132H3.26379V3.07547H5.84739C9.95159 3.07547 11.6938 5.09015 11.6938 9.19436C11.6938 13.2986 9.95159 15.3132 5.84739 15.3132Z" fill="#E8E8E8"/>
|
||||
<path d="M45.7219 5.79529L43.002 14.634L40.2822 5.79529H37.053L40.9979 17.2291C41.4085 18.4197 40.7139 19.3925 39.4552 19.3925H38.0728V22.1123H40.1047C41.8767 22.1123 43.4712 20.9918 44.0708 19.3244L48.9511 5.79529H45.7219Z" fill="#E8E8E8"/>
|
||||
<path d="M68.3027 17.3548H65.7889L61.2448 3.93987V17.3548H58.3684V0H62.6225L67.0941 13.294L71.5658 0H75.7232V17.3548H72.8468V3.93987L68.3027 17.3548Z" fill="#E8E8E8"/>
|
||||
<path d="M82.2376 17.5723C81.4319 17.5723 80.7068 17.4192 80.0622 17.1131C79.4177 16.8069 78.902 16.3718 78.5153 15.8078C78.1447 15.2438 77.9594 14.5832 77.9594 13.8258C77.9594 13.0201 78.1447 12.3594 78.5153 11.8438C78.8859 11.312 79.3935 10.885 80.0381 10.5627C80.6826 10.2404 81.4078 9.99873 82.2135 9.83759L85.7183 9.11246V8.89492C85.7183 8.28259 85.5491 7.80723 85.2107 7.46883C84.8723 7.11432 84.3163 6.93707 83.5429 6.93707C82.85 6.93707 82.3101 7.09821 81.9234 7.42049C81.5528 7.72666 81.2788 8.1859 81.1016 8.79823L78.3703 8.16979C78.6926 7.12238 79.3049 6.23611 80.2073 5.51098C81.1097 4.78585 82.2618 4.42329 83.6637 4.42329C85.1945 4.42329 86.387 4.78585 87.241 5.51098C88.1112 6.23611 88.5463 7.33186 88.5463 8.79823V14.2609C88.5463 14.6154 88.6268 14.8571 88.788 14.986C88.9652 15.1149 89.2553 15.1552 89.6581 15.1069V17.3548C88.6107 17.4676 87.797 17.4273 87.2169 17.2339C86.6367 17.0244 86.2339 16.6619 86.0083 16.1462C85.6055 16.5974 85.0817 16.9519 84.4372 17.2097C83.7926 17.4514 83.0594 17.5723 82.2376 17.5723ZM85.7183 12.8831V11.3362L82.9869 11.9163C82.3746 12.0452 81.859 12.2386 81.44 12.4964C81.0371 12.7381 80.8357 13.141 80.8357 13.7049C80.8357 14.2045 81.0049 14.5912 81.3433 14.8651C81.6817 15.123 82.1248 15.2519 82.6727 15.2519C83.1884 15.2519 83.6798 15.1633 84.1471 14.986C84.6144 14.8088 84.9931 14.5429 85.2832 14.1884C85.5732 13.8339 85.7183 13.3988 85.7183 12.8831Z" fill="#E8E8E8"/>
|
||||
<path d="M98.9216 4.64083V7.54134C98.7444 7.50912 98.5752 7.493 98.4141 7.493C98.2529 7.47689 98.0676 7.46883 97.8581 7.46883C96.9396 7.46883 96.1661 7.75083 95.5377 8.31482C94.9254 8.8788 94.6192 9.66839 94.6192 10.6836V17.3548H91.7187V4.665H94.6192V6.55033C94.8931 5.95412 95.3363 5.47875 95.9486 5.12425C96.5771 4.76974 97.2941 4.59249 98.0998 4.59249C98.2771 4.59249 98.4302 4.60054 98.5591 4.61666C98.688 4.61666 98.8088 4.62471 98.9216 4.64083Z" fill="#E8E8E8"/>
|
||||
<path d="M103.825 0V10.0068L108.707 4.665H112.261L107.475 9.59588L112.647 17.3548H109.288L105.493 11.6262L103.825 13.3182V17.3548H100.924V0H103.825Z" fill="#E8E8E8"/>
|
||||
<path d="M118.872 17.6206C117.663 17.6206 116.591 17.3467 115.657 16.7988C114.738 16.2348 114.013 15.4614 113.481 14.4784C112.966 13.4793 112.708 12.3272 112.708 11.022C112.708 9.78119 112.966 8.66127 113.481 7.6622C113.997 6.66313 114.706 5.87355 115.608 5.29344C116.527 4.71334 117.574 4.42329 118.751 4.42329C119.975 4.42329 121.007 4.70528 121.845 5.26927C122.683 5.81715 123.311 6.56645 123.73 7.51717C124.165 8.4679 124.383 9.52336 124.383 10.6836V11.6504H115.488C115.6 12.73 115.955 13.5841 116.551 14.2125C117.163 14.841 117.937 15.1552 118.872 15.1552C119.581 15.1552 120.201 14.9779 120.733 14.6234C121.264 14.2689 121.627 13.7694 121.82 13.1248L124.31 14.0675C123.859 15.1794 123.158 16.0495 122.207 16.678C121.256 17.3064 120.145 17.6206 118.872 17.6206ZM118.727 6.86456C117.969 6.86456 117.317 7.09015 116.769 7.54134C116.221 7.97642 115.842 8.62098 115.633 9.47502H121.458C121.442 8.76601 121.208 8.15368 120.757 7.63803C120.322 7.12238 119.645 6.86456 118.727 6.86456Z" fill="#E8E8E8"/>
|
||||
<path d="M126.825 14.1642V7.13044H125.06V4.665H126.825V0.942669H129.677V4.665H132.336V7.13044H129.677V13.7049C129.677 14.2689 129.83 14.6234 130.136 14.7685C130.442 14.8974 130.853 14.9618 131.369 14.9618C131.611 14.9618 131.812 14.9538 131.973 14.9377C132.15 14.9215 132.344 14.9054 132.553 14.8893V17.3306C132.295 17.3789 131.989 17.4192 131.635 17.4514C131.28 17.4837 130.918 17.4998 130.547 17.4998C129.339 17.4998 128.412 17.2661 127.767 16.7988C127.139 16.3315 126.825 15.4533 126.825 14.1642Z" fill="#E8E8E8"/>
|
||||
<path d="M141.276 17.6206C140.455 17.6206 139.737 17.4756 139.125 17.1856C138.529 16.8794 138.029 16.4927 137.627 16.0254V21.7055H134.726V4.665H137.627V6.01857C138.029 5.55127 138.529 5.17259 139.125 4.88254C139.737 4.57637 140.455 4.42329 141.276 4.42329C142.469 4.42329 143.476 4.7214 144.298 5.31761C145.136 5.91383 145.772 6.71147 146.207 7.71054C146.642 8.70961 146.86 9.81342 146.86 11.022C146.86 12.2144 146.642 13.3182 146.207 14.3334C145.772 15.3325 145.136 16.1301 144.298 16.7263C143.476 17.3225 142.469 17.6206 141.276 17.6206ZM137.554 10.6594V11.4087C137.554 12.585 137.852 13.4955 138.448 14.14C139.061 14.7685 139.81 15.0827 140.696 15.0827C141.744 15.0827 142.541 14.7121 143.089 13.9708C143.653 13.2135 143.935 12.2305 143.935 11.022C143.935 9.81342 143.653 8.83852 143.089 8.09728C142.541 7.33992 141.744 6.96124 140.696 6.96124C139.81 6.96124 139.061 7.28352 138.448 7.92808C137.852 8.55653 137.554 9.46697 137.554 10.6594Z" fill="#E8E8E8"/>
|
||||
<path d="M152.002 0V17.3548H149.101V0H152.002Z" fill="#E8E8E8"/>
|
||||
<path d="M158.409 17.5723C157.604 17.5723 156.878 17.4192 156.234 17.1131C155.589 16.8069 155.074 16.3718 154.687 15.8078C154.316 15.2438 154.131 14.5832 154.131 13.8258C154.131 13.0201 154.316 12.3594 154.687 11.8438C155.058 11.312 155.565 10.885 156.21 10.5627C156.854 10.2404 157.579 9.99873 158.385 9.83759L161.89 9.11246V8.89492C161.89 8.28259 161.721 7.80723 161.382 7.46883C161.044 7.11432 160.488 6.93707 159.714 6.93707C159.022 6.93707 158.482 7.09821 158.095 7.42049C157.724 7.72666 157.45 8.1859 157.273 8.79823L154.542 8.16979C154.864 7.12238 155.476 6.23611 156.379 5.51098C157.281 4.78585 158.433 4.42329 159.835 4.42329C161.366 4.42329 162.559 4.78585 163.413 5.51098C164.283 6.23611 164.718 7.33186 164.718 8.79823V14.2609C164.718 14.6154 164.798 14.8571 164.96 14.986C165.137 15.1149 165.427 15.1552 165.83 15.1069V17.3548C164.782 17.4676 163.969 17.4273 163.388 17.2339C162.808 17.0244 162.406 16.6619 162.18 16.1462C161.777 16.5974 161.253 16.9519 160.609 17.2097C159.964 17.4514 159.231 17.5723 158.409 17.5723ZM161.89 12.8831V11.3362L159.159 11.9163C158.546 12.0452 158.031 12.2386 157.612 12.4964C157.209 12.7381 157.007 13.141 157.007 13.7049C157.007 14.2045 157.177 14.5912 157.515 14.8651C157.853 15.123 158.296 15.2519 158.844 15.2519C159.36 15.2519 159.851 15.1633 160.319 14.986C160.786 14.8088 161.165 14.5429 161.455 14.1884C161.745 13.8339 161.89 13.3988 161.89 12.8831Z" fill="#E8E8E8"/>
|
||||
<path d="M169.993 11.022C169.993 12.3111 170.299 13.3182 170.912 14.0433C171.54 14.7524 172.346 15.1069 173.329 15.1069C174.102 15.1069 174.723 14.8813 175.19 14.4301C175.673 13.9628 176.004 13.3827 176.181 12.6898L178.671 13.9467C178.348 14.9779 177.72 15.8481 176.785 16.5571C175.867 17.2661 174.715 17.6206 173.329 17.6206C172.12 17.6206 171.041 17.3467 170.09 16.7988C169.155 16.2348 168.422 15.4614 167.89 14.4784C167.359 13.4793 167.093 12.3272 167.093 11.022C167.093 9.71673 167.359 8.57264 167.89 7.58969C168.422 6.59062 169.155 5.81715 170.09 5.26927C171.041 4.70528 172.12 4.42329 173.329 4.42329C174.698 4.42329 175.834 4.76974 176.737 5.46264C177.655 6.13943 178.284 6.98541 178.622 8.00059L176.181 9.33C176.004 8.63709 175.673 8.06505 175.19 7.61386C174.723 7.14655 174.102 6.9129 173.329 6.9129C172.346 6.9129 171.54 7.27546 170.912 8.00059C170.299 8.72572 169.993 9.73285 169.993 11.022Z" fill="#E8E8E8"/>
|
||||
<path d="M185.79 17.6206C184.582 17.6206 183.51 17.3467 182.575 16.7988C181.657 16.2348 180.932 15.4614 180.4 14.4784C179.884 13.4793 179.627 12.3272 179.627 11.022C179.627 9.78119 179.884 8.66127 180.4 7.6622C180.916 6.66313 181.625 5.87355 182.527 5.29344C183.446 4.71334 184.493 4.42329 185.669 4.42329C186.894 4.42329 187.925 4.70528 188.763 5.26927C189.601 5.81715 190.23 6.56645 190.649 7.51717C191.084 8.4679 191.301 9.52336 191.301 10.6836V11.6504H182.406C182.519 12.73 182.874 13.5841 183.47 14.2125C184.082 14.841 184.856 15.1552 185.79 15.1552C186.499 15.1552 187.12 14.9779 187.651 14.6234C188.183 14.2689 188.546 13.7694 188.739 13.1248L191.229 14.0675C190.778 15.1794 190.077 16.0495 189.126 16.678C188.175 17.3064 187.063 17.6206 185.79 17.6206ZM185.645 6.86456C184.888 6.86456 184.235 7.09015 183.687 7.54134C183.139 7.97642 182.761 8.62098 182.551 9.47502H188.377C188.36 8.76601 188.127 8.15368 187.676 7.63803C187.24 7.12238 186.564 6.86456 185.645 6.86456Z" fill="#E8E8E8"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.2 KiB |
@@ -0,0 +1,19 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="191.301" height="22.1123" viewBox="0 0 191.301 22.1123" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Vector">
|
||||
<path d="M21.6204 4.16343C23.0105 4.16343 23.5238 3.31151 23.5238 2.26003C23.5238 1.20856 23.0095 0.356634 21.6204 0.356634C20.2312 0.356634 19.717 1.20856 19.717 2.26003C19.717 3.31151 20.2312 4.16343 21.6204 4.16343Z" fill="#0033FF"/>
|
||||
<path d="M28.2832 4.57117V5.79533H25.1556V8.51515H28.2832V15.3142H23.116V5.79629H16.3169V8.51611H20.1247V15.3152H15.6377V18.035H36.034V15.3152H31.2745V8.51611H36.034V5.79629H31.2745V3.07646H36.034V0.356634H32.4987C30.1741 0.356634 28.2832 2.2466 28.2832 4.57117Z" fill="#0033FF"/>
|
||||
<path d="M5.77927 0.35564H0V18.0321H5.77927C12.918 18.0321 14.9576 13.9529 14.9576 9.1934C14.9576 4.43394 12.918 0.35564 5.77927 0.35564ZM5.84739 15.3132H3.26379V3.07547H5.84739C9.95159 3.07547 11.6938 5.09015 11.6938 9.19436C11.6938 13.2986 9.95159 15.3132 5.84739 15.3132Z" fill="black"/>
|
||||
<path d="M45.7219 5.79529L43.002 14.634L40.2822 5.79529H37.053L40.9979 17.2291C41.4085 18.4197 40.7139 19.3925 39.4552 19.3925H38.0728V22.1123H40.1047C41.8767 22.1123 43.4712 20.9918 44.0708 19.3244L48.9511 5.79529H45.7219Z" fill="black"/>
|
||||
<path d="M68.3027 17.3548H65.7889L61.2448 3.93987V17.3548H58.3684V0H62.6225L67.0941 13.294L71.5658 0H75.7232V17.3548H72.8468V3.93987L68.3027 17.3548Z" fill="black"/>
|
||||
<path d="M82.2376 17.5723C81.4319 17.5723 80.7068 17.4192 80.0622 17.1131C79.4177 16.8069 78.902 16.3718 78.5153 15.8078C78.1447 15.2438 77.9594 14.5832 77.9594 13.8258C77.9594 13.0201 78.1447 12.3594 78.5153 11.8438C78.8859 11.312 79.3935 10.885 80.0381 10.5627C80.6826 10.2404 81.4078 9.99873 82.2135 9.83759L85.7183 9.11246V8.89492C85.7183 8.28259 85.5491 7.80723 85.2107 7.46883C84.8723 7.11432 84.3163 6.93707 83.5429 6.93707C82.85 6.93707 82.3101 7.09821 81.9234 7.42049C81.5528 7.72666 81.2788 8.1859 81.1016 8.79823L78.3703 8.16979C78.6926 7.12238 79.3049 6.23611 80.2073 5.51098C81.1097 4.78585 82.2618 4.42329 83.6637 4.42329C85.1945 4.42329 86.387 4.78585 87.241 5.51098C88.1112 6.23611 88.5463 7.33186 88.5463 8.79823V14.2609C88.5463 14.6154 88.6268 14.8571 88.788 14.986C88.9652 15.1149 89.2553 15.1552 89.6581 15.1069V17.3548C88.6107 17.4676 87.797 17.4273 87.2169 17.2339C86.6367 17.0244 86.2339 16.6619 86.0083 16.1462C85.6055 16.5974 85.0817 16.9519 84.4372 17.2097C83.7926 17.4514 83.0594 17.5723 82.2376 17.5723ZM85.7183 12.8831V11.3362L82.9869 11.9163C82.3746 12.0452 81.859 12.2386 81.44 12.4964C81.0371 12.7381 80.8357 13.141 80.8357 13.7049C80.8357 14.2045 81.0049 14.5912 81.3433 14.8651C81.6817 15.123 82.1248 15.2519 82.6727 15.2519C83.1884 15.2519 83.6798 15.1633 84.1471 14.986C84.6144 14.8088 84.9931 14.5429 85.2832 14.1884C85.5732 13.8339 85.7183 13.3988 85.7183 12.8831Z" fill="black"/>
|
||||
<path d="M98.9216 4.64083V7.54134C98.7444 7.50912 98.5752 7.493 98.4141 7.493C98.2529 7.47689 98.0676 7.46883 97.8581 7.46883C96.9396 7.46883 96.1661 7.75083 95.5377 8.31482C94.9254 8.8788 94.6192 9.66839 94.6192 10.6836V17.3548H91.7187V4.665H94.6192V6.55033C94.8931 5.95412 95.3363 5.47875 95.9486 5.12425C96.5771 4.76974 97.2941 4.59249 98.0998 4.59249C98.2771 4.59249 98.4302 4.60054 98.5591 4.61666C98.688 4.61666 98.8088 4.62471 98.9216 4.64083Z" fill="black"/>
|
||||
<path d="M103.825 0V10.0068L108.707 4.665H112.261L107.475 9.59588L112.647 17.3548H109.288L105.493 11.6262L103.825 13.3182V17.3548H100.924V0H103.825Z" fill="black"/>
|
||||
<path d="M118.872 17.6206C117.663 17.6206 116.591 17.3467 115.657 16.7988C114.738 16.2348 114.013 15.4614 113.481 14.4784C112.966 13.4793 112.708 12.3272 112.708 11.022C112.708 9.78119 112.966 8.66127 113.481 7.6622C113.997 6.66313 114.706 5.87355 115.608 5.29344C116.527 4.71334 117.574 4.42329 118.751 4.42329C119.975 4.42329 121.007 4.70528 121.845 5.26927C122.683 5.81715 123.311 6.56645 123.73 7.51717C124.165 8.4679 124.383 9.52336 124.383 10.6836V11.6504H115.488C115.6 12.73 115.955 13.5841 116.551 14.2125C117.163 14.841 117.937 15.1552 118.872 15.1552C119.581 15.1552 120.201 14.9779 120.733 14.6234C121.264 14.2689 121.627 13.7694 121.82 13.1248L124.31 14.0675C123.859 15.1794 123.158 16.0495 122.207 16.678C121.256 17.3064 120.145 17.6206 118.872 17.6206ZM118.727 6.86456C117.969 6.86456 117.317 7.09015 116.769 7.54134C116.221 7.97642 115.842 8.62098 115.633 9.47502H121.458C121.442 8.76601 121.208 8.15368 120.757 7.63803C120.322 7.12238 119.645 6.86456 118.727 6.86456Z" fill="black"/>
|
||||
<path d="M126.825 14.1642V7.13044H125.06V4.665H126.825V0.942669H129.677V4.665H132.336V7.13044H129.677V13.7049C129.677 14.2689 129.83 14.6234 130.136 14.7685C130.442 14.8974 130.853 14.9618 131.369 14.9618C131.611 14.9618 131.812 14.9538 131.973 14.9377C132.15 14.9215 132.344 14.9054 132.553 14.8893V17.3306C132.295 17.3789 131.989 17.4192 131.635 17.4514C131.28 17.4837 130.918 17.4998 130.547 17.4998C129.339 17.4998 128.412 17.2661 127.767 16.7988C127.139 16.3315 126.825 15.4533 126.825 14.1642Z" fill="black"/>
|
||||
<path d="M141.276 17.6206C140.455 17.6206 139.737 17.4756 139.125 17.1856C138.529 16.8794 138.029 16.4927 137.627 16.0254V21.7055H134.726V4.665H137.627V6.01857C138.029 5.55127 138.529 5.17259 139.125 4.88254C139.737 4.57637 140.455 4.42329 141.276 4.42329C142.469 4.42329 143.476 4.7214 144.298 5.31761C145.136 5.91383 145.772 6.71147 146.207 7.71054C146.642 8.70961 146.86 9.81342 146.86 11.022C146.86 12.2144 146.642 13.3182 146.207 14.3334C145.772 15.3325 145.136 16.1301 144.298 16.7263C143.476 17.3225 142.469 17.6206 141.276 17.6206ZM137.554 10.6594V11.4087C137.554 12.585 137.852 13.4955 138.448 14.14C139.061 14.7685 139.81 15.0827 140.696 15.0827C141.744 15.0827 142.541 14.7121 143.089 13.9708C143.653 13.2135 143.935 12.2305 143.935 11.022C143.935 9.81342 143.653 8.83852 143.089 8.09728C142.541 7.33992 141.744 6.96124 140.696 6.96124C139.81 6.96124 139.061 7.28352 138.448 7.92808C137.852 8.55653 137.554 9.46697 137.554 10.6594Z" fill="black"/>
|
||||
<path d="M152.002 0V17.3548H149.101V0H152.002Z" fill="black"/>
|
||||
<path d="M158.409 17.5723C157.604 17.5723 156.878 17.4192 156.234 17.1131C155.589 16.8069 155.074 16.3718 154.687 15.8078C154.316 15.2438 154.131 14.5832 154.131 13.8258C154.131 13.0201 154.316 12.3594 154.687 11.8438C155.058 11.312 155.565 10.885 156.21 10.5627C156.854 10.2404 157.579 9.99873 158.385 9.83759L161.89 9.11246V8.89492C161.89 8.28259 161.721 7.80723 161.382 7.46883C161.044 7.11432 160.488 6.93707 159.714 6.93707C159.022 6.93707 158.482 7.09821 158.095 7.42049C157.724 7.72666 157.45 8.1859 157.273 8.79823L154.542 8.16979C154.864 7.12238 155.476 6.23611 156.379 5.51098C157.281 4.78585 158.433 4.42329 159.835 4.42329C161.366 4.42329 162.559 4.78585 163.413 5.51098C164.283 6.23611 164.718 7.33186 164.718 8.79823V14.2609C164.718 14.6154 164.798 14.8571 164.96 14.986C165.137 15.1149 165.427 15.1552 165.83 15.1069V17.3548C164.782 17.4676 163.969 17.4273 163.388 17.2339C162.808 17.0244 162.406 16.6619 162.18 16.1462C161.777 16.5974 161.253 16.9519 160.609 17.2097C159.964 17.4514 159.231 17.5723 158.409 17.5723ZM161.89 12.8831V11.3362L159.159 11.9163C158.546 12.0452 158.031 12.2386 157.612 12.4964C157.209 12.7381 157.007 13.141 157.007 13.7049C157.007 14.2045 157.177 14.5912 157.515 14.8651C157.853 15.123 158.296 15.2519 158.844 15.2519C159.36 15.2519 159.851 15.1633 160.319 14.986C160.786 14.8088 161.165 14.5429 161.455 14.1884C161.745 13.8339 161.89 13.3988 161.89 12.8831Z" fill="black"/>
|
||||
<path d="M169.993 11.022C169.993 12.3111 170.299 13.3182 170.912 14.0433C171.54 14.7524 172.346 15.1069 173.329 15.1069C174.102 15.1069 174.723 14.8813 175.19 14.4301C175.673 13.9628 176.004 13.3827 176.181 12.6898L178.671 13.9467C178.348 14.9779 177.72 15.8481 176.785 16.5571C175.867 17.2661 174.715 17.6206 173.329 17.6206C172.12 17.6206 171.041 17.3467 170.09 16.7988C169.155 16.2348 168.422 15.4614 167.89 14.4784C167.359 13.4793 167.093 12.3272 167.093 11.022C167.093 9.71673 167.359 8.57264 167.89 7.58969C168.422 6.59062 169.155 5.81715 170.09 5.26927C171.041 4.70528 172.12 4.42329 173.329 4.42329C174.698 4.42329 175.834 4.76974 176.737 5.46264C177.655 6.13943 178.284 6.98541 178.622 8.00059L176.181 9.33C176.004 8.63709 175.673 8.06505 175.19 7.61386C174.723 7.14655 174.102 6.9129 173.329 6.9129C172.346 6.9129 171.54 7.27546 170.912 8.00059C170.299 8.72572 169.993 9.73285 169.993 11.022Z" fill="black"/>
|
||||
<path d="M185.79 17.6206C184.582 17.6206 183.51 17.3467 182.575 16.7988C181.657 16.2348 180.932 15.4614 180.4 14.4784C179.884 13.4793 179.627 12.3272 179.627 11.022C179.627 9.78119 179.884 8.66127 180.4 7.6622C180.916 6.66313 181.625 5.87355 182.527 5.29344C183.446 4.71334 184.493 4.42329 185.669 4.42329C186.894 4.42329 187.925 4.70528 188.763 5.26927C189.601 5.81715 190.23 6.56645 190.649 7.51717C191.084 8.4679 191.301 9.52336 191.301 10.6836V11.6504H182.406C182.519 12.73 182.874 13.5841 183.47 14.2125C184.082 14.841 184.856 15.1552 185.79 15.1552C186.499 15.1552 187.12 14.9779 187.651 14.6234C188.183 14.2689 188.546 13.7694 188.739 13.1248L191.229 14.0675C190.778 15.1794 190.077 16.0495 189.126 16.678C188.175 17.3064 187.063 17.6206 185.79 17.6206ZM185.645 6.86456C184.888 6.86456 184.235 7.09015 183.687 7.54134C183.139 7.97642 182.761 8.62098 182.551 9.47502H188.377C188.36 8.76601 188.127 8.15368 187.676 7.63803C187.24 7.12238 186.564 6.86456 185.645 6.86456Z" fill="black"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.1 KiB |
Reference in New Issue
Block a user