mirror of
https://github.com/langgenius/dify.git
synced 2026-09-05 00:31:19 +08:00
fix: address marketplace review feedback on CI lane, templates and carousel a11y
- move the marketplace performance E2E lane out of main CI (opt-in workflow_dispatch) - add server-rendered pagination for template search and validate page/view/sort params at the route boundary - match template locale filtering by language prefix with an explicit English fallback - cache the template collection fan-out (TTL + in-flight dedupe), lower per-collection limit and batch requests - reset the forced search mode atom when the plugin catalog mounts - clear stale search suggestions during the debounce window - let the trending Play control override focus/hover pause reasons - honor prefers-reduced-motion on the eagerly playing first-collection carousel - give the Creator Center link an accessible, localized name - replace the nested main landmark on the templates page with a div - localize banner read-more actions and backfill marketplace.home keys across all 24 locales Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
05acab169b
commit
e7ca2735bf
61
.github/workflows/main-ci.yml
vendored
61
.github/workflows/main-ci.yml
vendored
@ -108,7 +108,6 @@ jobs:
|
||||
- 'docker/docker-compose.middleware.yaml'
|
||||
- 'docker/envs/middleware.env.example'
|
||||
- '.github/workflows/web-e2e.yml'
|
||||
- '.github/workflows/marketplace-performance-e2e.yml'
|
||||
- '.github/workflows/main-ci.yml'
|
||||
- '.github/actions/setup-web/**'
|
||||
vdb:
|
||||
@ -394,66 +393,6 @@ jobs:
|
||||
echo "Web full-stack E2E was not required, but the skip job finished with result: $SKIP_RESULT" >&2
|
||||
exit 1
|
||||
|
||||
marketplace-performance-run:
|
||||
name: Run Marketplace Performance E2E
|
||||
needs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.e2e-changed == 'true'
|
||||
uses: ./.github/workflows/marketplace-performance-e2e.yml
|
||||
secrets: inherit
|
||||
|
||||
marketplace-performance-skip:
|
||||
name: Skip Marketplace Performance E2E
|
||||
needs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.e2e-changed != 'true'
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Report skipped marketplace performance E2E
|
||||
run: echo "No E2E-related changes detected; skipping marketplace performance E2E."
|
||||
|
||||
marketplace-performance:
|
||||
name: Marketplace Performance E2E
|
||||
if: ${{ always() }}
|
||||
needs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
- marketplace-performance-run
|
||||
- marketplace-performance-skip
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Finalize Marketplace Performance E2E status
|
||||
env:
|
||||
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
|
||||
TESTS_CHANGED: ${{ needs.check-changes.outputs.e2e-changed }}
|
||||
RUN_RESULT: ${{ needs.marketplace-performance-run.result }}
|
||||
SKIP_RESULT: ${{ needs.marketplace-performance-skip.result }}
|
||||
run: |
|
||||
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
|
||||
echo "Marketplace performance E2E was skipped because this workflow run duplicated a successful or newer run."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$TESTS_CHANGED" == 'true' ]]; then
|
||||
if [[ "$RUN_RESULT" == 'success' ]]; then
|
||||
echo "Marketplace performance E2E ran successfully."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Marketplace performance E2E was required but finished with result: $RUN_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_RESULT" == 'success' ]]; then
|
||||
echo "Marketplace performance E2E was skipped because no E2E-related files changed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Marketplace performance E2E was not required, but the skip job finished with result: $SKIP_RESULT" >&2
|
||||
exit 1
|
||||
|
||||
style-check:
|
||||
name: Style Check
|
||||
needs: pre_job
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
name: Marketplace Performance E2E
|
||||
|
||||
# Opt-in diagnostic: single-sample timing budgets are too noisy to gate every
|
||||
# PR, so this lane is only run on demand instead of from the main CI pipeline.
|
||||
on:
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@ -4,8 +4,24 @@ import { redirect } from '@/next/navigation'
|
||||
import TemplatesPage from '../page'
|
||||
|
||||
vi.mock('@/app/components/plugins/marketplace/templates', () => ({
|
||||
EmbeddedTemplatesMarketplace: ({ category, query }: { category: string; query: string }) => (
|
||||
<div>{`Templates catalog: ${category}:${query}`}</div>
|
||||
EmbeddedTemplatesMarketplace: ({
|
||||
category,
|
||||
page,
|
||||
query,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
view,
|
||||
}: {
|
||||
category: string
|
||||
page: number
|
||||
query: string
|
||||
sortBy?: string
|
||||
sortOrder?: string
|
||||
view?: string
|
||||
}) => (
|
||||
<div data-testid="catalog" data-page={page} data-sort-by={sortBy} data-sort-order={sortOrder} data-view={view}>
|
||||
{`Templates catalog: ${category}:${query}`}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
@ -46,6 +62,48 @@ describe('embedded templates route', () => {
|
||||
expect(screen.getByText('Templates catalog: marketing:')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('validates page, view and sort params at the route boundary', async () => {
|
||||
const page = await TemplatesPage({
|
||||
params: Promise.resolve({}),
|
||||
searchParams: Promise.resolve({
|
||||
page: '3',
|
||||
q: 'agent',
|
||||
sort_by: 'created_at',
|
||||
sort_order: 'ASC',
|
||||
view: 'search',
|
||||
}),
|
||||
})
|
||||
|
||||
render(page)
|
||||
|
||||
const catalog = screen.getByTestId('catalog')
|
||||
expect(catalog).toHaveAttribute('data-page', '3')
|
||||
expect(catalog).toHaveAttribute('data-sort-by', 'created_at')
|
||||
expect(catalog).toHaveAttribute('data-sort-order', 'ASC')
|
||||
expect(catalog).toHaveAttribute('data-view', 'search')
|
||||
})
|
||||
|
||||
it('falls back to defaults for unsupported page, view and sort params', async () => {
|
||||
const page = await TemplatesPage({
|
||||
params: Promise.resolve({}),
|
||||
searchParams: Promise.resolve({
|
||||
page: '-2',
|
||||
q: 'agent',
|
||||
sort_by: 'garbage',
|
||||
sort_order: 'sideways',
|
||||
view: 'iframe',
|
||||
}),
|
||||
})
|
||||
|
||||
render(page)
|
||||
|
||||
const catalog = screen.getByTestId('catalog')
|
||||
expect(catalog).toHaveAttribute('data-page', '1')
|
||||
expect(catalog).not.toHaveAttribute('data-sort-by')
|
||||
expect(catalog).not.toHaveAttribute('data-sort-order')
|
||||
expect(catalog).not.toHaveAttribute('data-view')
|
||||
})
|
||||
|
||||
it('opens template recommendations in the existing Dify import flow', async () => {
|
||||
await expect(
|
||||
TemplatesPage({
|
||||
|
||||
@ -6,6 +6,7 @@ import { redirect } from '@/next/navigation'
|
||||
type TemplatesPageProps = {
|
||||
params: Promise<{ category?: string[] }>
|
||||
searchParams: Promise<{
|
||||
page?: string
|
||||
q?: string
|
||||
sort_by?: string
|
||||
sort_order?: string
|
||||
@ -14,6 +15,26 @@ type TemplatesPageProps = {
|
||||
}>
|
||||
}
|
||||
|
||||
// These values arrive from a public URL, so validate them against the
|
||||
// supported enums here at the route boundary. Unknown values fall back to the
|
||||
// defaults instead of reaching the Marketplace API, where e.g.
|
||||
// `sort_order=garbage` fails and would surface as a false "no templates" state.
|
||||
const TEMPLATE_SORT_FIELDS = new Set(['usage_count', 'created_at'])
|
||||
const TEMPLATE_SORT_ORDERS = new Set(['ASC', 'DESC'])
|
||||
|
||||
const parseView = (value?: string) => (value === 'search' ? 'search' : undefined)
|
||||
|
||||
const parseSortBy = (value?: string) =>
|
||||
value && TEMPLATE_SORT_FIELDS.has(value) ? value : undefined
|
||||
|
||||
const parseSortOrder = (value?: string) =>
|
||||
value && TEMPLATE_SORT_ORDERS.has(value) ? value : undefined
|
||||
|
||||
const parsePage = (value?: string) => {
|
||||
const parsed = Number(value)
|
||||
return Number.isInteger(parsed) && parsed >= 1 ? parsed : 1
|
||||
}
|
||||
|
||||
export default async function TemplatesPage({ params, searchParams }: TemplatesPageProps) {
|
||||
const [resolvedParams, resolvedSearchParams, locale] = await Promise.all([
|
||||
params,
|
||||
@ -36,10 +57,11 @@ export default async function TemplatesPage({ params, searchParams }: TemplatesP
|
||||
<EmbeddedTemplatesMarketplace
|
||||
category={category}
|
||||
locale={locale}
|
||||
page={parsePage(resolvedSearchParams.page)}
|
||||
query={resolvedSearchParams.q ?? ''}
|
||||
sortBy={resolvedSearchParams.sort_by}
|
||||
sortOrder={resolvedSearchParams.sort_order}
|
||||
view={resolvedSearchParams.view}
|
||||
sortBy={parseSortBy(resolvedSearchParams.sort_by)}
|
||||
sortOrder={parseSortOrder(resolvedSearchParams.sort_order)}
|
||||
view={parseView(resolvedSearchParams.view)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -2,7 +2,7 @@ import type { PluginsSort, SearchParamsFromCollection } from '@dify/contracts/ma
|
||||
import type { ActivePluginType } from './constants'
|
||||
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { DEFAULT_SORT, PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants'
|
||||
import { marketplaceSearchParamsParsers } from './search-params'
|
||||
|
||||
@ -43,6 +43,21 @@ export function useMarketplaceSearchMode(activePluginTypeOverride?: ActivePlugin
|
||||
return isSearchMode
|
||||
}
|
||||
|
||||
/**
|
||||
* The forced search mode lives in the app-wide Jotai store, so a "View More"
|
||||
* click would otherwise leak into the next visit of the plugin catalog after
|
||||
* navigating away (e.g. to /templates) and back, rendering empty-query search
|
||||
* results instead of the prefetched collections. Reset it when the catalog
|
||||
* route mounts; URL-owned state (q, tags, category) is not affected.
|
||||
*/
|
||||
export function useResetMarketplaceSearchModeOnMount() {
|
||||
const setSearchMode = useSetAtom(searchModeAtom)
|
||||
|
||||
useEffect(() => {
|
||||
setSearchMode(null)
|
||||
}, [setSearchMode])
|
||||
}
|
||||
|
||||
export function useMarketplaceMoreClick() {
|
||||
const [, setQ] = useSearchPluginText()
|
||||
const setSort = useSetAtom(marketplaceSortAtom)
|
||||
|
||||
@ -5,6 +5,7 @@ import type { MarketplaceViewProps } from './view'
|
||||
import { queryOptions, useQuery } from '@tanstack/react-query'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { marketplaceQuery } from '@/service/client'
|
||||
import { useResetMarketplaceSearchModeOnMount } from './atoms'
|
||||
import { fetchPluginBanners } from './home/banners'
|
||||
import { MarketplaceView } from './view'
|
||||
|
||||
@ -27,6 +28,7 @@ export function EmbeddedMarketplace({
|
||||
variant = 'default',
|
||||
...props
|
||||
}: EmbeddedMarketplaceProps) {
|
||||
useResetMarketplaceSearchModeOnMount()
|
||||
const locale = useLocale()
|
||||
const input = {
|
||||
query: {
|
||||
|
||||
@ -44,7 +44,7 @@ describe('HomeHeader', () => {
|
||||
it('shows Creator Center before Guide', () => {
|
||||
render(<HomeHeader isMarketplacePlatform />)
|
||||
|
||||
const creatorCenterLink = screen.getByRole('link', { name: 'Creator Center' })
|
||||
const creatorCenterLink = screen.getByRole('link', { name: 'marketplace.home.creatorCenter' })
|
||||
const guideLink = screen.getByRole('link', { name: 'marketplace.home.guide' })
|
||||
|
||||
expect(creatorCenterLink).toHaveAttribute('href', 'https://creators.dify.ai/')
|
||||
@ -63,7 +63,7 @@ describe('HomeHeader', () => {
|
||||
|
||||
render(<HomeHeader isMarketplacePlatform />)
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Creator Center' })).toHaveAttribute(
|
||||
expect(screen.getByRole('link', { name: 'marketplace.home.creatorCenter' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://creators-staging.dify.dev/',
|
||||
)
|
||||
@ -74,7 +74,7 @@ describe('HomeHeader', () => {
|
||||
|
||||
render(<HomeHeader isMarketplacePlatform />)
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Creator Center' })).toHaveAttribute(
|
||||
expect(screen.getByRole('link', { name: 'marketplace.home.creatorCenter' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://creators.dify.ai/',
|
||||
)
|
||||
|
||||
@ -121,7 +121,7 @@ describe('HomeTrending', () => {
|
||||
expect(screen.getByRole('heading', { name: 'Dify v1.9 new launch' })).toBeInTheDocument()
|
||||
const blogSlide = screen.getByRole('group', { name: 'Dify Updates' })
|
||||
const blogLink = within(blogSlide).getByRole('link', {
|
||||
name: 'Read more about Dify v1.9 new launch',
|
||||
name: 'plugin.marketplace.home.trendingReadMoreAbout',
|
||||
})
|
||||
expect(blogLink).toHaveAttribute('href', 'https://dify.ai/blog')
|
||||
expect(within(blogSlide).getAllByRole('link')).toHaveLength(1)
|
||||
@ -365,6 +365,51 @@ describe('HomeTrending', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('resumes autoplay when Play is activated without moving keyboard focus', async () => {
|
||||
const pause = vi.fn()
|
||||
const play = vi.fn()
|
||||
const progressAnimation = {
|
||||
cancel: vi.fn(),
|
||||
onfinish: null,
|
||||
pause,
|
||||
play,
|
||||
} as unknown as Animation
|
||||
const originalAnimate = Element.prototype.animate
|
||||
Object.defineProperty(Element.prototype, 'animate', {
|
||||
configurable: true,
|
||||
value: vi.fn(() => progressAnimation),
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<HomeTrending banners={banners} isMarketplacePlatform />)
|
||||
|
||||
const toggleButton = screen.getByRole('button', {
|
||||
name: 'plugin.marketplace.home.trendingPause',
|
||||
})
|
||||
|
||||
// Focusing the toggle adds the implicit focus pause reason, then Enter
|
||||
// adds the explicit user pause.
|
||||
toggleButton.focus()
|
||||
await user.keyboard('{Enter}')
|
||||
expect(pause).toHaveBeenCalled()
|
||||
|
||||
// Play must resume the rotation even though the button is still focused
|
||||
// (and would normally keep the focus pause reason active).
|
||||
const playsBeforePlay = play.mock.calls.length
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(play.mock.calls.length).toBeGreaterThan(playsBeforePlay)
|
||||
expect(document.activeElement).toBe(toggleButton)
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'plugin.marketplace.home.trendingPause' }),
|
||||
).toBeInTheDocument()
|
||||
|
||||
Object.defineProperty(Element.prototype, 'animate', {
|
||||
configurable: true,
|
||||
value: originalAnimate,
|
||||
})
|
||||
})
|
||||
|
||||
it('renders no carousel when the API returns no banners', () => {
|
||||
render(<HomeTrending banners={[]} isMarketplacePlatform />)
|
||||
|
||||
|
||||
@ -3,13 +3,16 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useState } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import {
|
||||
MarketplaceSearchAutocomplete,
|
||||
MarketplaceSearchForm,
|
||||
} from '../marketplace-search-autocomplete'
|
||||
|
||||
const { mockPluginSearch, mockTemplateSearch } = vi.hoisted(() => ({
|
||||
const { debounceState, mockPluginSearch, mockTemplateSearch } = vi.hoisted(() => ({
|
||||
// Most tests bypass the debounce for simplicity; the debounce-window test
|
||||
// flips this on to exercise the real 300ms lag.
|
||||
debounceState: { useRealDebounce: false },
|
||||
mockPluginSearch: vi.fn(),
|
||||
mockTemplateSearch: vi.fn(),
|
||||
}))
|
||||
@ -19,7 +22,8 @@ vi.mock('ahooks', async (importOriginal) => {
|
||||
|
||||
return {
|
||||
...original,
|
||||
useDebounce: <T,>(value: T) => value,
|
||||
useDebounce: <T,>(value: T, options?: { wait?: number }) =>
|
||||
debounceState.useRealDebounce ? original.useDebounce(value, options) : value,
|
||||
}
|
||||
})
|
||||
|
||||
@ -60,6 +64,7 @@ function Wrapper({ children }: { children: ReactNode }) {
|
||||
describe('MarketplaceSearchAutocomplete', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
debounceState.useRealDebounce = false
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
@ -219,4 +224,51 @@ describe('MarketplaceSearchAutocomplete', () => {
|
||||
expect(screen.queryByText('Google Search')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Searching...')
|
||||
})
|
||||
|
||||
it('clears suggestions while the edited value is still debouncing', async () => {
|
||||
debounceState.useRealDebounce = true
|
||||
mockPluginSearch.mockResolvedValue({
|
||||
data: {
|
||||
plugins: [
|
||||
{
|
||||
type: 'plugin',
|
||||
org: 'langgenius',
|
||||
name: 'google-search',
|
||||
label: { en_US: 'Google Search' },
|
||||
brief: { en_US: 'Search the web from your workflow.' },
|
||||
category: 'tool',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
|
||||
const ControlledSearch = () => {
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
return (
|
||||
<MarketplaceSearchAutocomplete
|
||||
locale="en-US"
|
||||
onValueChange={setValue}
|
||||
placeholder="Search plugins"
|
||||
scope="plugins"
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
render(<ControlledSearch />, { wrapper: Wrapper })
|
||||
|
||||
// Suggestions only appear once the real 300ms debounce has elapsed.
|
||||
await user.type(screen.getByRole('combobox'), 'google')
|
||||
expect(await screen.findByText('Google Search')).toBeInTheDocument()
|
||||
|
||||
// For the first 300ms after editing, the debounced term still points at
|
||||
// the old query; the previous suggestions must already be gone.
|
||||
await user.type(screen.getByRole('combobox'), ' drive')
|
||||
|
||||
expect(screen.queryByText('Google Search')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Searching...')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { HomeCatalogTab, HomeCatalogTabLabels } from './home-catalog-tabs'
|
||||
import { buttonVariants } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useTranslation } from '#i18n'
|
||||
import { MARKETPLACE_URL_PREFIX } from '@/config'
|
||||
import Link from '@/next/link'
|
||||
import MarketplaceLogoDark from '@/public/marketplace/dify-marketplace-logo-dark.svg'
|
||||
@ -39,20 +40,25 @@ const getCreatorCenterUrl = (marketplaceUrlPrefix: string) => {
|
||||
}
|
||||
|
||||
const CreatorCenter = () => {
|
||||
const { t } = useTranslation('plugin')
|
||||
const creatorCenterUrl = getCreatorCenterUrl(MARKETPLACE_URL_PREFIX)
|
||||
const label = t(($) => $['marketplace.home.creatorCenter'])
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={creatorCenterUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
// The visible text is hidden below the lg breakpoint, so the link needs
|
||||
// an explicit accessible name to avoid becoming an icon-only mystery.
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
'flex items-center gap-1 px-3 py-2 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary [html[data-theme=dark]_&]:text-text-primary [html[data-theme=dark]_&]:hover:text-text-primary',
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-user-star-line size-4" />
|
||||
<span className="hidden system-sm-medium lg:inline">Creator Center</span>
|
||||
<span className="hidden system-sm-medium lg:inline">{label}</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
@ -231,6 +231,7 @@ function TrendingRecommendationSlide({
|
||||
}
|
||||
|
||||
function BlogBannerSlide({ banner }: { banner: BannerBlog }) {
|
||||
const { t } = useTranslation('plugin')
|
||||
const opensInNewTab = /^https?:\/\//.test(banner.content.link)
|
||||
|
||||
return (
|
||||
@ -238,7 +239,9 @@ function BlogBannerSlide({ banner }: { banner: BannerBlog }) {
|
||||
href={banner.content.link}
|
||||
target={opensInNewTab ? '_blank' : undefined}
|
||||
rel={opensInNewTab ? 'noopener noreferrer' : undefined}
|
||||
aria-label={`Read more about ${banner.content.blog_title}`}
|
||||
aria-label={t(($) => $['marketplace.home.trendingReadMoreAbout'], {
|
||||
title: banner.content.blog_title,
|
||||
})}
|
||||
className="flex h-[200px] w-full overflow-hidden rounded-2xl bg-background-body outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col items-start overflow-hidden px-6 py-5">
|
||||
@ -265,7 +268,7 @@ function BlogBannerSlide({ banner }: { banner: BannerBlog }) {
|
||||
aria-hidden
|
||||
className="flex shrink-0 items-center gap-1 text-[13px] leading-[normal] font-medium text-text-accent underline decoration-[10%] underline-offset-2"
|
||||
>
|
||||
<span>Read more</span>
|
||||
<span>{t(($) => $['marketplace.home.trendingReadMore'])}</span>
|
||||
<span className="i-ri-arrow-right-s-line size-4" />
|
||||
</span>
|
||||
</div>
|
||||
@ -480,6 +483,12 @@ function TrendingNavigation({
|
||||
setIsReducedMotionPaused(false)
|
||||
setPauseReason('user', false)
|
||||
setPauseReason('reduced-motion', false)
|
||||
// Activating Play keeps the pointer and/or keyboard focus on the button
|
||||
// itself, so the implicit hover/focus reasons would silently keep the
|
||||
// rotation paused. An explicit Play overrides them; they re-engage on
|
||||
// the next mouseenter/focusin.
|
||||
setPauseReason('focus', false)
|
||||
setPauseReason('hover', false)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -121,16 +121,22 @@ export function MarketplaceSearchAutocomplete({
|
||||
enabled: hasQuery && searchesTemplates,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
const pluginSuggestions = searchesPlugins
|
||||
? (pluginQuery.data?.data.bundles ?? pluginQuery.data?.data.plugins ?? []).map((plugin) =>
|
||||
toPluginSuggestion(plugin, locale),
|
||||
)
|
||||
: []
|
||||
const templateSuggestions = searchesTemplates
|
||||
? (templateQuery.data?.data?.templates ?? []).map(toTemplateSuggestion)
|
||||
: []
|
||||
// While the edited value is still debouncing, the queries above still hold
|
||||
// the previous term's data; gate the suggestions until both agree so stale
|
||||
// options are never visible or keyboard-selectable.
|
||||
const isDebouncing = value.trim() !== debouncedSearch
|
||||
const pluginSuggestions =
|
||||
!isDebouncing && searchesPlugins
|
||||
? (pluginQuery.data?.data.bundles ?? pluginQuery.data?.data.plugins ?? []).map((plugin) =>
|
||||
toPluginSuggestion(plugin, locale),
|
||||
)
|
||||
: []
|
||||
const templateSuggestions =
|
||||
!isDebouncing && searchesTemplates
|
||||
? (templateQuery.data?.data?.templates ?? []).map(toTemplateSuggestion)
|
||||
: []
|
||||
const suggestions = [...templateSuggestions, ...pluginSuggestions]
|
||||
const isSearching = pluginQuery.isFetching || templateQuery.isFetching
|
||||
const isSearching = isDebouncing || pluginQuery.isFetching || templateQuery.isFetching
|
||||
const emptyText =
|
||||
scope === 'templates'
|
||||
? translate('newApp.noTemplateFound', { ns: 'app' })
|
||||
|
||||
@ -232,7 +232,7 @@ describe('Marketplace Carousel', () => {
|
||||
expect(autoplay.play).toHaveBeenCalledTimes(2)
|
||||
|
||||
reducedMotion = true
|
||||
reducedMotionListener?.()
|
||||
act(() => reducedMotionListener?.())
|
||||
expect(autoplay.stop).toHaveBeenCalled()
|
||||
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
@ -248,7 +248,7 @@ describe('Marketplace Carousel', () => {
|
||||
expect(autoplay.play).toHaveBeenCalledTimes(2)
|
||||
|
||||
reducedMotion = false
|
||||
reducedMotionListener?.()
|
||||
act(() => reducedMotionListener?.())
|
||||
expect(autoplay.play).toHaveBeenCalledTimes(3)
|
||||
|
||||
triggerIntersection(intersectionObservers[0]!, 0)
|
||||
@ -268,6 +268,38 @@ describe('Marketplace Carousel', () => {
|
||||
expect(intersectionObservers).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('honors reduced motion for the eagerly playing first-collection carousel', () => {
|
||||
let reducedMotion = true
|
||||
let reducedMotionListener: (() => void) | undefined
|
||||
vi.stubGlobal('matchMedia', () => ({
|
||||
get matches() {
|
||||
return reducedMotion
|
||||
},
|
||||
media: '(prefers-reduced-motion: reduce)',
|
||||
onchange: null,
|
||||
addEventListener: (_event: string, listener: () => void) => {
|
||||
reducedMotionListener = listener
|
||||
},
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
// The production first collection renders without pauseWhenOffscreen, so
|
||||
// the reduced-motion guard must work outside the viewport-managed path.
|
||||
render(<Carousel pages={pages} autoPlay />)
|
||||
const autoplay = mocks.autoplayInstances[0]!
|
||||
|
||||
expect(autoplay.stop).toHaveBeenCalled()
|
||||
expect(autoplay.play).not.toHaveBeenCalled()
|
||||
|
||||
reducedMotion = false
|
||||
act(() => reducedMotionListener?.())
|
||||
|
||||
expect(autoplay.play).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps off-screen pages out of the tab order and accessibility tree', () => {
|
||||
render(<Carousel pages={pages} ariaLabel="Featured tools" />)
|
||||
|
||||
|
||||
@ -168,6 +168,13 @@ const Carousel = ({
|
||||
}: CarouselProps) => {
|
||||
const carouselRootRef = useRef<HTMLDivElement>(null)
|
||||
const [isUserPaused, setIsUserPaused] = useState(false)
|
||||
// Tracked independently of pauseWhenOffscreen so every autoplay path honors
|
||||
// prefers-reduced-motion, including the eagerly-playing first collection.
|
||||
const [isReducedMotion, setIsReducedMotion] = useState(
|
||||
() =>
|
||||
typeof window !== 'undefined' &&
|
||||
(window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false),
|
||||
)
|
||||
const autoplay = useMemo(() => {
|
||||
if (!autoPlay) return undefined
|
||||
|
||||
@ -264,12 +271,28 @@ const Carousel = ({
|
||||
return () => carouselRoot.removeEventListener('focusin', handleFocusIn)
|
||||
}, [autoplay])
|
||||
|
||||
// The viewport-managed effect below tracks reduced motion itself; this
|
||||
// effect covers the eager autoplay path (pauseWhenOffscreen=false), which
|
||||
// previously ignored the preference entirely.
|
||||
useEffect(() => {
|
||||
if (!autoPlay || pauseWhenOffscreen) return
|
||||
|
||||
const reducedMotionQuery = window.matchMedia?.('(prefers-reduced-motion: reduce)')
|
||||
if (!reducedMotionQuery) return
|
||||
|
||||
const syncReducedMotion = () => setIsReducedMotion(reducedMotionQuery.matches)
|
||||
|
||||
syncReducedMotion()
|
||||
reducedMotionQuery.addEventListener('change', syncReducedMotion)
|
||||
return () => reducedMotionQuery.removeEventListener('change', syncReducedMotion)
|
||||
}, [autoPlay, pauseWhenOffscreen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoplay || !api || pauseWhenOffscreen) return
|
||||
|
||||
if (isUserPaused) autoplay.stop()
|
||||
if (isUserPaused || isReducedMotion) autoplay.stop()
|
||||
else autoplay.play()
|
||||
}, [api, autoplay, isUserPaused, pauseWhenOffscreen])
|
||||
}, [api, autoplay, isReducedMotion, isUserPaused, pauseWhenOffscreen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!pauseWhenOffscreen || !autoplay || !api) return
|
||||
|
||||
@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from 'vite-plus/test'
|
||||
import { filterTemplatesForLocale } from '../template-language'
|
||||
|
||||
const template = (id: string, preferredLanguages?: string[]) => ({
|
||||
id,
|
||||
preferred_languages: preferredLanguages,
|
||||
})
|
||||
|
||||
const ids = (templates: { id: string }[]) => templates.map(({ id }) => id)
|
||||
|
||||
describe('filterTemplatesForLocale', () => {
|
||||
it('keeps templates matching the requested language prefix', () => {
|
||||
const templates = [
|
||||
template('en', ['en-US']),
|
||||
template('zh', ['zh-Hans']),
|
||||
template('ja', ['ja-JP']),
|
||||
]
|
||||
|
||||
expect(ids(filterTemplatesForLocale(templates, 'zh-Hans'))).toEqual(['zh'])
|
||||
expect(ids(filterTemplatesForLocale(templates, 'en-US'))).toEqual(['en'])
|
||||
})
|
||||
|
||||
it('matches unrelated locales instead of collapsing them into "other"', () => {
|
||||
const templates = [
|
||||
template('en', ['en-US']),
|
||||
template('de', ['de-DE']),
|
||||
template('fr', ['fr-FR']),
|
||||
]
|
||||
|
||||
expect(ids(filterTemplatesForLocale(templates, 'de-DE'))).toEqual(['de'])
|
||||
})
|
||||
|
||||
it('falls back to English templates when nothing matches the requested language', () => {
|
||||
const templates = [
|
||||
template('en-1', ['en-US']),
|
||||
template('en-2', ['en-GB']),
|
||||
template('ja', ['ja-JP']),
|
||||
]
|
||||
|
||||
expect(ids(filterTemplatesForLocale(templates, 'de-DE'))).toEqual(['en-1', 'en-2'])
|
||||
})
|
||||
|
||||
it('falls back to the unfiltered list when neither the locale nor English matches', () => {
|
||||
const templates = [template('zh', ['zh-Hans']), template('ja', ['ja-JP'])]
|
||||
|
||||
expect(ids(filterTemplatesForLocale(templates, 'de-DE'))).toEqual(['zh', 'ja'])
|
||||
})
|
||||
|
||||
it('always keeps language-agnostic templates', () => {
|
||||
const templates = [
|
||||
template('agnostic-none'),
|
||||
template('agnostic-empty', []),
|
||||
template('de', ['de-DE']),
|
||||
]
|
||||
|
||||
expect(ids(filterTemplatesForLocale(templates, 'de-DE'))).toEqual([
|
||||
'agnostic-none',
|
||||
'agnostic-empty',
|
||||
'de',
|
||||
])
|
||||
})
|
||||
|
||||
it('normalizes underscore locales', () => {
|
||||
const templates = [template('zh', ['zh_Hans']), template('en', ['en_US'])]
|
||||
|
||||
expect(ids(filterTemplatesForLocale(templates, 'zh_Hans'))).toEqual(['zh'])
|
||||
})
|
||||
})
|
||||
@ -8,6 +8,7 @@ import Link from '@/next/link'
|
||||
import {
|
||||
getMarketplaceTemplateCollectionsAndTemplates,
|
||||
searchMarketplaceTemplates,
|
||||
TEMPLATE_SEARCH_PAGE_SIZE,
|
||||
} from '@/service/marketplace-template-discovery'
|
||||
import { fetchPluginBanners } from '../home/banners'
|
||||
import HomeCatalogNavigation from '../home/home-catalog-navigation'
|
||||
@ -29,6 +30,7 @@ import { filterTemplatesForLocale } from './template-language'
|
||||
type EmbeddedTemplatesMarketplaceProps = {
|
||||
category: TemplateCategory
|
||||
locale: Locale
|
||||
page?: number
|
||||
query: string
|
||||
sortBy?: string
|
||||
sortOrder?: string
|
||||
@ -103,9 +105,81 @@ function TemplateGrid({
|
||||
)
|
||||
}
|
||||
|
||||
const PAGE_LINK_CLASS =
|
||||
'flex h-8 items-center justify-center rounded-lg border-[0.5px] border-divider-regular px-3 system-sm-medium text-text-secondary outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid'
|
||||
const PAGE_LINK_DISABLED_CLASS =
|
||||
'flex h-8 cursor-not-allowed items-center justify-center rounded-lg border-[0.5px] border-divider-subtle px-3 system-sm-medium text-text-quaternary'
|
||||
|
||||
// Server-rendered pagination: plain links keep the search results reachable
|
||||
// beyond the first page without any client-side state.
|
||||
function TemplatePagination({
|
||||
category,
|
||||
navigationLabel,
|
||||
nextLabel,
|
||||
page,
|
||||
pageCount,
|
||||
previousLabel,
|
||||
query,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
view,
|
||||
}: {
|
||||
category: TemplateCategory
|
||||
navigationLabel: string
|
||||
nextLabel: string
|
||||
page: number
|
||||
pageCount: number
|
||||
previousLabel: string
|
||||
query: string
|
||||
sortBy?: string
|
||||
sortOrder?: string
|
||||
view?: string
|
||||
}) {
|
||||
if (pageCount <= 1) return null
|
||||
|
||||
const buildHref = (targetPage: number) => {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (query) searchParams.set('q', query)
|
||||
if (sortBy) searchParams.set('sort_by', sortBy)
|
||||
if (sortOrder) searchParams.set('sort_order', sortOrder)
|
||||
if (view) searchParams.set('view', view)
|
||||
if (targetPage > 1) searchParams.set('page', String(targetPage))
|
||||
const queryString = searchParams.toString()
|
||||
const basePath = category === 'all' ? '/templates' : `/templates/${category}`
|
||||
return queryString ? `${basePath}?${queryString}` : basePath
|
||||
}
|
||||
|
||||
return (
|
||||
<nav aria-label={navigationLabel} className="mt-6 flex items-center justify-center gap-3 pb-4">
|
||||
{page > 1 ? (
|
||||
<Link href={buildHref(page - 1)} className={PAGE_LINK_CLASS}>
|
||||
{previousLabel}
|
||||
</Link>
|
||||
) : (
|
||||
<span aria-disabled="true" className={PAGE_LINK_DISABLED_CLASS}>
|
||||
{previousLabel}
|
||||
</span>
|
||||
)}
|
||||
<span aria-current="page" className="system-sm-regular text-text-tertiary">
|
||||
{page} / {pageCount}
|
||||
</span>
|
||||
{page < pageCount ? (
|
||||
<Link href={buildHref(page + 1)} className={PAGE_LINK_CLASS}>
|
||||
{nextLabel}
|
||||
</Link>
|
||||
) : (
|
||||
<span aria-disabled="true" className={PAGE_LINK_DISABLED_CLASS}>
|
||||
{nextLabel}
|
||||
</span>
|
||||
)}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
export async function EmbeddedTemplatesMarketplace({
|
||||
category,
|
||||
locale,
|
||||
page = 1,
|
||||
query,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
@ -118,6 +192,7 @@ export async function EmbeddedTemplatesMarketplace({
|
||||
{ t: tApp },
|
||||
{ t: tExplore },
|
||||
{ t: tPluginTags },
|
||||
{ t: tCommon },
|
||||
collectionsResult,
|
||||
searchResult,
|
||||
banners,
|
||||
@ -126,11 +201,13 @@ export async function EmbeddedTemplatesMarketplace({
|
||||
getTranslation(locale, 'app'),
|
||||
getTranslation(locale, 'explore'),
|
||||
getTranslation(locale, 'pluginTags'),
|
||||
getTranslation(locale, 'common'),
|
||||
showCollections ? getMarketplaceTemplateCollectionsAndTemplates() : Promise.resolve(null),
|
||||
showCollections
|
||||
? Promise.resolve(null)
|
||||
: searchMarketplaceTemplates({
|
||||
category,
|
||||
page,
|
||||
query: normalizedQuery,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
@ -214,7 +291,9 @@ export async function EmbeddedTemplatesMarketplace({
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<main
|
||||
{/* The app shell already renders the main landmark; use a plain div
|
||||
to avoid nested main elements. */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex grow flex-col bg-background-default px-8 py-2',
|
||||
styles.catalogContent,
|
||||
@ -243,9 +322,21 @@ export async function EmbeddedTemplatesMarketplace({
|
||||
) : (
|
||||
<EmptyState>{tApp('newApp.noTemplateFound' as never)}</EmptyState>
|
||||
)}
|
||||
<TemplatePagination
|
||||
category={category}
|
||||
navigationLabel={tCommon('pagination.pageNumber' as never)}
|
||||
nextLabel={tCommon('pagination.next' as never)}
|
||||
page={page}
|
||||
pageCount={Math.ceil((searchResult?.total ?? 0) / TEMPLATE_SEARCH_PAGE_SIZE)}
|
||||
previousLabel={tCommon('pagination.previous' as never)}
|
||||
query={normalizedQuery}
|
||||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
view={view}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</HomeStickyStateProvider>
|
||||
|
||||
@ -1,36 +1,33 @@
|
||||
import type { MarketplaceTemplate } from '@dify/contracts/marketplace'
|
||||
|
||||
type TemplateLanguageFamily = 'en' | 'ja' | 'other' | 'zh'
|
||||
|
||||
function getTemplateLanguageFamily(locale: string): TemplateLanguageFamily {
|
||||
const normalizedLocale = locale.toLowerCase()
|
||||
|
||||
if (normalizedLocale.startsWith('en')) return 'en'
|
||||
if (normalizedLocale.startsWith('zh')) return 'zh'
|
||||
if (normalizedLocale.startsWith('ja')) return 'ja'
|
||||
|
||||
return 'other'
|
||||
}
|
||||
const getLanguagePrefix = (locale: string) => locale.toLowerCase().split(/[-_]/)[0] ?? ''
|
||||
|
||||
/**
|
||||
* Keeps the templates matching the requested locale's language. Templates
|
||||
* without language metadata are treated as language-agnostic and always kept.
|
||||
* When no template matches the requested language, the list explicitly falls
|
||||
* back to English templates (and finally to the unfiltered list) so locales
|
||||
* such as German render real content instead of an empty state.
|
||||
*/
|
||||
export function filterTemplatesForLocale<
|
||||
T extends Pick<MarketplaceTemplate, 'preferred_languages'>,
|
||||
>(templates: T[], locale: string) {
|
||||
const languageFamily = getTemplateLanguageFamily(locale)
|
||||
const requestedLanguage = getLanguagePrefix(locale)
|
||||
|
||||
return templates.filter((template) => {
|
||||
const preferredLanguages = (template.preferred_languages ?? []).map((language) =>
|
||||
language.toLowerCase(),
|
||||
)
|
||||
const filterByLanguage = (languagePrefix: string) =>
|
||||
templates.filter((template) => {
|
||||
const preferredLanguages = template.preferred_languages ?? []
|
||||
if (preferredLanguages.length === 0) return true
|
||||
return preferredLanguages.some((language) => getLanguagePrefix(language) === languagePrefix)
|
||||
})
|
||||
|
||||
if (languageFamily === 'other') {
|
||||
return !preferredLanguages.some(
|
||||
(language) =>
|
||||
language.startsWith('en') || language.startsWith('zh') || language.startsWith('ja'),
|
||||
)
|
||||
}
|
||||
const requestedMatches = filterByLanguage(requestedLanguage)
|
||||
if (requestedMatches.length > 0) return requestedMatches
|
||||
|
||||
return preferredLanguages.some((language) => language.startsWith(languageFamily))
|
||||
})
|
||||
const englishMatches = requestedLanguage === 'en' ? [] : filterByLanguage('en')
|
||||
if (englishMatches.length > 0) return englishMatches
|
||||
|
||||
return templates
|
||||
}
|
||||
|
||||
export function getTemplateCollectionText(value: Record<string, string>, locale: string) {
|
||||
|
||||
@ -227,6 +227,7 @@
|
||||
"marketplace.difyMarketplace": "سوق Dify",
|
||||
"marketplace.discover": "اكتشف",
|
||||
"marketplace.empower": "تمكين تطوير الذكاء الاصطناعي الخاص بك",
|
||||
"marketplace.home.creatorCenter": "مركز المبدعين",
|
||||
"marketplace.home.guide": "دليل",
|
||||
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
|
||||
"marketplace.home.heroTitle": "اكتشف. وسّع. ابنِ",
|
||||
@ -238,7 +239,12 @@
|
||||
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
|
||||
"marketplace.home.trendingEyebrow": "Trending Now",
|
||||
"marketplace.home.trendingPaginationLabel": "Trending pages",
|
||||
"marketplace.home.trendingPause": "إيقاف مؤقت",
|
||||
"marketplace.home.trendingPlay": "تشغيل",
|
||||
"marketplace.home.trendingReadMore": "اقرأ المزيد",
|
||||
"marketplace.home.trendingReadMoreAbout": "اقرأ المزيد عن {{title}}",
|
||||
"marketplace.home.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.home.trendingView": "عرض",
|
||||
"marketplace.moreFrom": "المزيد من السوق",
|
||||
"marketplace.noPluginFound": "لم يتم العثور على إضافة",
|
||||
"marketplace.partnerTip": "تم التحقق بواسطة شريك Dify",
|
||||
|
||||
@ -227,6 +227,7 @@
|
||||
"marketplace.difyMarketplace": "Dify Marktplatz",
|
||||
"marketplace.discover": "Entdecken",
|
||||
"marketplace.empower": "Unterstützen Sie Ihre KI-Entwicklung",
|
||||
"marketplace.home.creatorCenter": "Creator Center",
|
||||
"marketplace.home.guide": "Leitfaden",
|
||||
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
|
||||
"marketplace.home.heroTitle": "Entdecken. Erweitern. Entwickeln",
|
||||
@ -238,7 +239,12 @@
|
||||
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
|
||||
"marketplace.home.trendingEyebrow": "Trending Now",
|
||||
"marketplace.home.trendingPaginationLabel": "Trending pages",
|
||||
"marketplace.home.trendingPause": "Pausieren",
|
||||
"marketplace.home.trendingPlay": "Abspielen",
|
||||
"marketplace.home.trendingReadMore": "Mehr erfahren",
|
||||
"marketplace.home.trendingReadMoreAbout": "Mehr erfahren über {{title}}",
|
||||
"marketplace.home.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.home.trendingView": "Ansehen",
|
||||
"marketplace.moreFrom": "Mehr aus dem Marketplace",
|
||||
"marketplace.noPluginFound": "Kein Plugin gefunden",
|
||||
"marketplace.partnerTip": "Von einem Dify-Partner verifiziert",
|
||||
|
||||
@ -227,6 +227,7 @@
|
||||
"marketplace.difyMarketplace": "Dify Marketplace",
|
||||
"marketplace.discover": "Discover",
|
||||
"marketplace.empower": "Empower your AI development",
|
||||
"marketplace.home.creatorCenter": "Creator Center",
|
||||
"marketplace.home.guide": "Guide",
|
||||
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
|
||||
"marketplace.home.heroTitle": "Discover. Extend. Build",
|
||||
@ -240,6 +241,8 @@
|
||||
"marketplace.home.trendingPaginationLabel": "Trending pages",
|
||||
"marketplace.home.trendingPause": "Pause",
|
||||
"marketplace.home.trendingPlay": "Play",
|
||||
"marketplace.home.trendingReadMore": "Read more",
|
||||
"marketplace.home.trendingReadMoreAbout": "Read more about {{title}}",
|
||||
"marketplace.home.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.home.trendingView": "View",
|
||||
"marketplace.moreFrom": "More from Marketplace",
|
||||
|
||||
@ -227,6 +227,7 @@
|
||||
"marketplace.difyMarketplace": "Mercado de Dify",
|
||||
"marketplace.discover": "Descubrir",
|
||||
"marketplace.empower": "Potencie su desarrollo de IA",
|
||||
"marketplace.home.creatorCenter": "Centro de creadores",
|
||||
"marketplace.home.guide": "Guía",
|
||||
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
|
||||
"marketplace.home.heroTitle": "Descubre. Amplía. Crea",
|
||||
@ -238,7 +239,12 @@
|
||||
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
|
||||
"marketplace.home.trendingEyebrow": "Trending Now",
|
||||
"marketplace.home.trendingPaginationLabel": "Trending pages",
|
||||
"marketplace.home.trendingPause": "Pausar",
|
||||
"marketplace.home.trendingPlay": "Reproducir",
|
||||
"marketplace.home.trendingReadMore": "Leer más",
|
||||
"marketplace.home.trendingReadMoreAbout": "Leer más sobre {{title}}",
|
||||
"marketplace.home.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.home.trendingView": "Ver",
|
||||
"marketplace.moreFrom": "Más de Marketplace",
|
||||
"marketplace.noPluginFound": "No se ha encontrado ninguna integración",
|
||||
"marketplace.partnerTip": "Verificado por un socio de Dify",
|
||||
|
||||
@ -227,6 +227,7 @@
|
||||
"marketplace.difyMarketplace": "بازار دیفی",
|
||||
"marketplace.discover": "کشف",
|
||||
"marketplace.empower": "توسعه هوش مصنوعی خود را توانمند کنید",
|
||||
"marketplace.home.creatorCenter": "مرکز سازندگان",
|
||||
"marketplace.home.guide": "راهنما",
|
||||
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
|
||||
"marketplace.home.heroTitle": "کشف کنید. گسترش دهید. بسازید",
|
||||
@ -238,7 +239,12 @@
|
||||
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
|
||||
"marketplace.home.trendingEyebrow": "Trending Now",
|
||||
"marketplace.home.trendingPaginationLabel": "Trending pages",
|
||||
"marketplace.home.trendingPause": "توقف",
|
||||
"marketplace.home.trendingPlay": "پخش",
|
||||
"marketplace.home.trendingReadMore": "ادامه مطلب",
|
||||
"marketplace.home.trendingReadMoreAbout": "ادامه مطلب درباره {{title}}",
|
||||
"marketplace.home.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.home.trendingView": "مشاهده",
|
||||
"marketplace.moreFrom": "اطلاعات بیشتر از Marketplace",
|
||||
"marketplace.noPluginFound": "هیچ افزونهای یافت نشد",
|
||||
"marketplace.partnerTip": "تأیید شده توسط یک شریک دیفی",
|
||||
|
||||
@ -227,6 +227,7 @@
|
||||
"marketplace.difyMarketplace": "Marché Dify",
|
||||
"marketplace.discover": "Découvrir",
|
||||
"marketplace.empower": "Renforcez le développement de votre IA",
|
||||
"marketplace.home.creatorCenter": "Centre des créateurs",
|
||||
"marketplace.home.guide": "Guide",
|
||||
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
|
||||
"marketplace.home.heroTitle": "Découvrez. Étendez. Créez",
|
||||
@ -238,7 +239,12 @@
|
||||
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
|
||||
"marketplace.home.trendingEyebrow": "Trending Now",
|
||||
"marketplace.home.trendingPaginationLabel": "Trending pages",
|
||||
"marketplace.home.trendingPause": "Mettre en pause",
|
||||
"marketplace.home.trendingPlay": "Lire",
|
||||
"marketplace.home.trendingReadMore": "En savoir plus",
|
||||
"marketplace.home.trendingReadMoreAbout": "En savoir plus sur {{title}}",
|
||||
"marketplace.home.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.home.trendingView": "Voir",
|
||||
"marketplace.moreFrom": "Plus de Marketplace",
|
||||
"marketplace.noPluginFound": "Aucune intégration trouvée",
|
||||
"marketplace.partnerTip": "Vérifié par un partenaire Dify",
|
||||
|
||||
@ -227,6 +227,7 @@
|
||||
"marketplace.difyMarketplace": "डिफाई मार्केटप्लेस",
|
||||
"marketplace.discover": "खोजें",
|
||||
"marketplace.empower": "अपने एआई विकास को सशक्त बनाएं",
|
||||
"marketplace.home.creatorCenter": "क्रिएटर केंद्र",
|
||||
"marketplace.home.guide": "मार्गदर्शिका",
|
||||
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
|
||||
"marketplace.home.heroTitle": "खोजें। विस्तार करें। बनाएँ",
|
||||
@ -238,7 +239,12 @@
|
||||
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
|
||||
"marketplace.home.trendingEyebrow": "Trending Now",
|
||||
"marketplace.home.trendingPaginationLabel": "Trending pages",
|
||||
"marketplace.home.trendingPause": "रोकें",
|
||||
"marketplace.home.trendingPlay": "चलाएं",
|
||||
"marketplace.home.trendingReadMore": "और पढ़ें",
|
||||
"marketplace.home.trendingReadMoreAbout": "{{title}} के बारे में और पढ़ें",
|
||||
"marketplace.home.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.home.trendingView": "देखें",
|
||||
"marketplace.moreFrom": "मार्केटप्लेस से अधिक",
|
||||
"marketplace.noPluginFound": "कोई इंटीग्रेशन नहीं मिला",
|
||||
"marketplace.partnerTip": "Dify भागीदार द्वारा सत्यापित",
|
||||
|
||||
@ -227,6 +227,7 @@
|
||||
"marketplace.difyMarketplace": "Dify Marketplace",
|
||||
"marketplace.discover": "Menemukan",
|
||||
"marketplace.empower": "Berdayakan pengembangan AI Anda",
|
||||
"marketplace.home.creatorCenter": "Pusat Kreator",
|
||||
"marketplace.home.guide": "Panduan",
|
||||
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
|
||||
"marketplace.home.heroTitle": "Temukan. Perluas. Bangun",
|
||||
@ -238,7 +239,12 @@
|
||||
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
|
||||
"marketplace.home.trendingEyebrow": "Trending Now",
|
||||
"marketplace.home.trendingPaginationLabel": "Trending pages",
|
||||
"marketplace.home.trendingPause": "Jeda",
|
||||
"marketplace.home.trendingPlay": "Putar",
|
||||
"marketplace.home.trendingReadMore": "Baca selengkapnya",
|
||||
"marketplace.home.trendingReadMoreAbout": "Baca selengkapnya tentang {{title}}",
|
||||
"marketplace.home.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.home.trendingView": "Lihat",
|
||||
"marketplace.moreFrom": "Selengkapnya dari Marketplace",
|
||||
"marketplace.noPluginFound": "Tidak ada integrasi yang ditemukan",
|
||||
"marketplace.partnerTip": "Diverifikasi oleh partner Dify",
|
||||
|
||||
@ -227,6 +227,7 @@
|
||||
"marketplace.difyMarketplace": "Mercato Dify",
|
||||
"marketplace.discover": "Scoprire",
|
||||
"marketplace.empower": "Potenzia lo sviluppo dell'intelligenza artificiale",
|
||||
"marketplace.home.creatorCenter": "Centro creatori",
|
||||
"marketplace.home.guide": "Guida",
|
||||
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
|
||||
"marketplace.home.heroTitle": "Scopri. Estendi. Crea",
|
||||
@ -238,7 +239,12 @@
|
||||
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
|
||||
"marketplace.home.trendingEyebrow": "Trending Now",
|
||||
"marketplace.home.trendingPaginationLabel": "Trending pages",
|
||||
"marketplace.home.trendingPause": "Pausa",
|
||||
"marketplace.home.trendingPlay": "Riproduci",
|
||||
"marketplace.home.trendingReadMore": "Scopri di più",
|
||||
"marketplace.home.trendingReadMoreAbout": "Scopri di più su {{title}}",
|
||||
"marketplace.home.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.home.trendingView": "Visualizza",
|
||||
"marketplace.moreFrom": "Altro da Marketplace",
|
||||
"marketplace.noPluginFound": "Nessuna integrazione trovata",
|
||||
"marketplace.partnerTip": "Verificato da un partner Dify",
|
||||
|
||||
@ -227,6 +227,7 @@
|
||||
"marketplace.difyMarketplace": "Dify マーケットプレイス",
|
||||
"marketplace.discover": "探索",
|
||||
"marketplace.empower": "AI 開発をサポートする",
|
||||
"marketplace.home.creatorCenter": "クリエイターセンター",
|
||||
"marketplace.home.guide": "ガイド",
|
||||
"marketplace.home.heroSubtitle": "Dify Marketplace で、より安全で信頼性の高いプラグインを見つけましょう。",
|
||||
"marketplace.home.heroTitle": "見つける。拡張する。構築する",
|
||||
@ -238,7 +239,12 @@
|
||||
"marketplace.home.trendingDescription": "実際の利用状況に基づく人気プラグインを2週間ごとに更新。ワークスペースでの実行数によるランキングで、有料掲載や編集部による選定はありません。",
|
||||
"marketplace.home.trendingEyebrow": "トレンド",
|
||||
"marketplace.home.trendingPaginationLabel": "トレンドページ",
|
||||
"marketplace.home.trendingPause": "一時停止",
|
||||
"marketplace.home.trendingPlay": "再生",
|
||||
"marketplace.home.trendingReadMore": "続きを読む",
|
||||
"marketplace.home.trendingReadMoreAbout": "{{title}} の続きを読む",
|
||||
"marketplace.home.trendingTitle": "みんながインストールしているプラグイン",
|
||||
"marketplace.home.trendingView": "表示",
|
||||
"marketplace.moreFrom": "マーケットプレイスからのさらなる情報",
|
||||
"marketplace.noPluginFound": "インテグレーションが見つかりません",
|
||||
"marketplace.partnerTip": "このプラグインは Dify のパートナーによって認証されています",
|
||||
|
||||
@ -227,6 +227,7 @@
|
||||
"marketplace.difyMarketplace": "Dify 마켓플레이스",
|
||||
"marketplace.discover": "발견하다",
|
||||
"marketplace.empower": "AI 개발 역량 강화",
|
||||
"marketplace.home.creatorCenter": "크리에이터 센터",
|
||||
"marketplace.home.guide": "가이드",
|
||||
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
|
||||
"marketplace.home.heroTitle": "발견하고, 확장하고, 구축하세요",
|
||||
@ -238,7 +239,12 @@
|
||||
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
|
||||
"marketplace.home.trendingEyebrow": "Trending Now",
|
||||
"marketplace.home.trendingPaginationLabel": "Trending pages",
|
||||
"marketplace.home.trendingPause": "일시정지",
|
||||
"marketplace.home.trendingPlay": "재생",
|
||||
"marketplace.home.trendingReadMore": "더 알아보기",
|
||||
"marketplace.home.trendingReadMoreAbout": "{{title}}에 대해 더 알아보기",
|
||||
"marketplace.home.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.home.trendingView": "보기",
|
||||
"marketplace.moreFrom": "Marketplace 에서 더 보기",
|
||||
"marketplace.noPluginFound": "플러그인을 찾을 수 없습니다.",
|
||||
"marketplace.partnerTip": "Dify 파트너에 의해 확인됨",
|
||||
|
||||
@ -227,6 +227,12 @@
|
||||
"marketplace.difyMarketplace": "Dify Marketplace",
|
||||
"marketplace.discover": "ຄົ້ນຫາ",
|
||||
"marketplace.empower": "ເສີມພະລັງການພັດທະນາ AI ຂອງທ່ານ",
|
||||
"marketplace.home.creatorCenter": "ສູນຜູ້ສ້າງ",
|
||||
"marketplace.home.trendingPause": "ຢຸດຊົ່ວຄາວ",
|
||||
"marketplace.home.trendingPlay": "ຫຼິ້ນ",
|
||||
"marketplace.home.trendingReadMore": "ອ່ານເພີ່ມເຕີມ",
|
||||
"marketplace.home.trendingReadMoreAbout": "ອ່ານເພີ່ມເຕີມກ່ຽວກັບ {{title}}",
|
||||
"marketplace.home.trendingView": "ເບິ່ງ",
|
||||
"marketplace.moreFrom": "ເພີ່ມເຕີມຈາກ Marketplace",
|
||||
"marketplace.noPluginFound": "ບໍ່ພົບການເຊື່ອມຕໍ່",
|
||||
"marketplace.partnerTip": "ໄດ້ຮັບການຢືນຢັນໂດຍພັດທະນາມິດຂອງ Dify",
|
||||
|
||||
@ -227,6 +227,7 @@
|
||||
"marketplace.difyMarketplace": "Dify Marketplace",
|
||||
"marketplace.discover": "Discover",
|
||||
"marketplace.empower": "Empower your AI development",
|
||||
"marketplace.home.creatorCenter": "Creatorcentrum",
|
||||
"marketplace.home.guide": "Handleiding",
|
||||
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
|
||||
"marketplace.home.heroTitle": "Ontdek. Breid uit. Bouw",
|
||||
@ -238,7 +239,12 @@
|
||||
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
|
||||
"marketplace.home.trendingEyebrow": "Trending Now",
|
||||
"marketplace.home.trendingPaginationLabel": "Trending pages",
|
||||
"marketplace.home.trendingPause": "Pauzeren",
|
||||
"marketplace.home.trendingPlay": "Afspelen",
|
||||
"marketplace.home.trendingReadMore": "Lees meer",
|
||||
"marketplace.home.trendingReadMoreAbout": "Lees meer over {{title}}",
|
||||
"marketplace.home.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.home.trendingView": "Bekijken",
|
||||
"marketplace.moreFrom": "More from Marketplace",
|
||||
"marketplace.noPluginFound": "Geen plugin gevonden",
|
||||
"marketplace.partnerTip": "Verified by a Dify partner",
|
||||
|
||||
@ -227,6 +227,7 @@
|
||||
"marketplace.difyMarketplace": "Rynek Dify",
|
||||
"marketplace.discover": "Odkryć",
|
||||
"marketplace.empower": "Zwiększ możliwości rozwoju sztucznej inteligencji",
|
||||
"marketplace.home.creatorCenter": "Centrum twórców",
|
||||
"marketplace.home.guide": "Przewodnik",
|
||||
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
|
||||
"marketplace.home.heroTitle": "Odkrywaj. Rozszerzaj. Twórz",
|
||||
@ -238,7 +239,12 @@
|
||||
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
|
||||
"marketplace.home.trendingEyebrow": "Trending Now",
|
||||
"marketplace.home.trendingPaginationLabel": "Trending pages",
|
||||
"marketplace.home.trendingPause": "Wstrzymaj",
|
||||
"marketplace.home.trendingPlay": "Odtwórz",
|
||||
"marketplace.home.trendingReadMore": "Czytaj więcej",
|
||||
"marketplace.home.trendingReadMoreAbout": "Czytaj więcej o {{title}}",
|
||||
"marketplace.home.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.home.trendingView": "Zobacz",
|
||||
"marketplace.moreFrom": "Więcej z Marketplace",
|
||||
"marketplace.noPluginFound": "Nie znaleziono integracji",
|
||||
"marketplace.partnerTip": "Zweryfikowane przez partnera Dify",
|
||||
|
||||
@ -227,6 +227,7 @@
|
||||
"marketplace.difyMarketplace": "Mercado Dify",
|
||||
"marketplace.discover": "Descobrir",
|
||||
"marketplace.empower": "Capacite seu desenvolvimento de IA",
|
||||
"marketplace.home.creatorCenter": "Central do criador",
|
||||
"marketplace.home.guide": "Guia",
|
||||
"marketplace.home.heroSubtitle": "Crie com plugins mais seguros e confiáveis do Dify Marketplace.",
|
||||
"marketplace.home.heroTitle": "Descubra. Expanda. Crie",
|
||||
@ -238,7 +239,12 @@
|
||||
"marketplace.home.trendingDescription": "Destaques por uso real, atualizados a cada duas semanas. Classificados pelas execuções reais nos espaços de trabalho — sem promoção paga ou seleção editorial.",
|
||||
"marketplace.home.trendingEyebrow": "Em alta agora",
|
||||
"marketplace.home.trendingPaginationLabel": "Páginas em alta",
|
||||
"marketplace.home.trendingPause": "Pausar",
|
||||
"marketplace.home.trendingPlay": "Reproduzir",
|
||||
"marketplace.home.trendingReadMore": "Leia mais",
|
||||
"marketplace.home.trendingReadMoreAbout": "Leia mais sobre {{title}}",
|
||||
"marketplace.home.trendingTitle": "Os plugins que todos estão instalando",
|
||||
"marketplace.home.trendingView": "Ver",
|
||||
"marketplace.moreFrom": "Mais do Marketplace",
|
||||
"marketplace.noPluginFound": "Nenhuma integração encontrada",
|
||||
"marketplace.partnerTip": "Verificado por um parceiro da Dify",
|
||||
|
||||
@ -227,6 +227,7 @@
|
||||
"marketplace.difyMarketplace": "Piața Dify",
|
||||
"marketplace.discover": "Descoperi",
|
||||
"marketplace.empower": "Îmbunătățește-ți dezvoltarea AI",
|
||||
"marketplace.home.creatorCenter": "Centrul creatorilor",
|
||||
"marketplace.home.guide": "Ghid",
|
||||
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
|
||||
"marketplace.home.heroTitle": "Descoperă. Extinde. Construiește",
|
||||
@ -238,7 +239,12 @@
|
||||
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
|
||||
"marketplace.home.trendingEyebrow": "Trending Now",
|
||||
"marketplace.home.trendingPaginationLabel": "Trending pages",
|
||||
"marketplace.home.trendingPause": "Pauză",
|
||||
"marketplace.home.trendingPlay": "Redare",
|
||||
"marketplace.home.trendingReadMore": "Citește mai mult",
|
||||
"marketplace.home.trendingReadMoreAbout": "Citește mai mult despre {{title}}",
|
||||
"marketplace.home.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.home.trendingView": "Vezi",
|
||||
"marketplace.moreFrom": "Mai multe din Marketplace",
|
||||
"marketplace.noPluginFound": "Nu s-a găsit niciun plugin",
|
||||
"marketplace.partnerTip": "Verificat de un partener Dify",
|
||||
|
||||
@ -227,6 +227,7 @@
|
||||
"marketplace.difyMarketplace": "Торговая площадка Dify",
|
||||
"marketplace.discover": "Обнаруживать",
|
||||
"marketplace.empower": "Расширьте возможности разработки ИИ",
|
||||
"marketplace.home.creatorCenter": "Центр авторов",
|
||||
"marketplace.home.guide": "Руководство",
|
||||
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
|
||||
"marketplace.home.heroTitle": "Открывайте. Расширяйте. Создавайте",
|
||||
@ -238,7 +239,12 @@
|
||||
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
|
||||
"marketplace.home.trendingEyebrow": "Trending Now",
|
||||
"marketplace.home.trendingPaginationLabel": "Trending pages",
|
||||
"marketplace.home.trendingPause": "Пауза",
|
||||
"marketplace.home.trendingPlay": "Воспроизвести",
|
||||
"marketplace.home.trendingReadMore": "Читать далее",
|
||||
"marketplace.home.trendingReadMoreAbout": "Подробнее о {{title}}",
|
||||
"marketplace.home.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.home.trendingView": "Открыть",
|
||||
"marketplace.moreFrom": "Больше из Marketplace",
|
||||
"marketplace.noPluginFound": "Плагин не найден",
|
||||
"marketplace.partnerTip": "Подтверждено партнером Dify",
|
||||
|
||||
@ -227,6 +227,7 @@
|
||||
"marketplace.difyMarketplace": "Dify Marketplace",
|
||||
"marketplace.discover": "Odkrijte",
|
||||
"marketplace.empower": "Okrepite svoj razvoj AI",
|
||||
"marketplace.home.creatorCenter": "Središče za ustvarjalce",
|
||||
"marketplace.home.guide": "Vodnik",
|
||||
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
|
||||
"marketplace.home.heroTitle": "Odkrijte. Razširite. Ustvarite",
|
||||
@ -238,7 +239,12 @@
|
||||
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
|
||||
"marketplace.home.trendingEyebrow": "Trending Now",
|
||||
"marketplace.home.trendingPaginationLabel": "Trending pages",
|
||||
"marketplace.home.trendingPause": "Premor",
|
||||
"marketplace.home.trendingPlay": "Predvajaj",
|
||||
"marketplace.home.trendingReadMore": "Preberi več",
|
||||
"marketplace.home.trendingReadMoreAbout": "Preberi več o {{title}}",
|
||||
"marketplace.home.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.home.trendingView": "Ogled",
|
||||
"marketplace.moreFrom": "Več iz tržnice",
|
||||
"marketplace.noPluginFound": "Nobenega vtičnika ni bilo najti.",
|
||||
"marketplace.partnerTip": "Potrjeno s strani partnerja Dify",
|
||||
|
||||
@ -227,6 +227,7 @@
|
||||
"marketplace.difyMarketplace": "ตลาด Dify",
|
||||
"marketplace.discover": "ค้นพบ",
|
||||
"marketplace.empower": "เพิ่มศักยภาพในการพัฒนา AI ของคุณ",
|
||||
"marketplace.home.creatorCenter": "ศูนย์ครีเอเตอร์",
|
||||
"marketplace.home.guide": "คู่มือ",
|
||||
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
|
||||
"marketplace.home.heroTitle": "ค้นพบ ขยาย และสร้าง",
|
||||
@ -238,7 +239,12 @@
|
||||
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
|
||||
"marketplace.home.trendingEyebrow": "Trending Now",
|
||||
"marketplace.home.trendingPaginationLabel": "Trending pages",
|
||||
"marketplace.home.trendingPause": "หยุดชั่วคราว",
|
||||
"marketplace.home.trendingPlay": "เล่น",
|
||||
"marketplace.home.trendingReadMore": "อ่านเพิ่มเติม",
|
||||
"marketplace.home.trendingReadMoreAbout": "อ่านเพิ่มเติมเกี่ยวกับ {{title}}",
|
||||
"marketplace.home.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.home.trendingView": "ดู",
|
||||
"marketplace.moreFrom": "แอปเพิ่มเติมจาก Marketplace",
|
||||
"marketplace.noPluginFound": "ไม่พบปลั๊กอิน",
|
||||
"marketplace.partnerTip": "ได้รับการตรวจสอบโดยพันธมิตรของ Dify",
|
||||
|
||||
@ -227,6 +227,7 @@
|
||||
"marketplace.difyMarketplace": "Dify Pazar Yeri",
|
||||
"marketplace.discover": "Keşfet",
|
||||
"marketplace.empower": "Yapay zeka geliştirmenizi güçlendirin",
|
||||
"marketplace.home.creatorCenter": "İçerik Üretici Merkezi",
|
||||
"marketplace.home.guide": "Kılavuz",
|
||||
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
|
||||
"marketplace.home.heroTitle": "Keşfet. Genişlet. Oluştur",
|
||||
@ -238,7 +239,12 @@
|
||||
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
|
||||
"marketplace.home.trendingEyebrow": "Trending Now",
|
||||
"marketplace.home.trendingPaginationLabel": "Trending pages",
|
||||
"marketplace.home.trendingPause": "Duraklat",
|
||||
"marketplace.home.trendingPlay": "Oynat",
|
||||
"marketplace.home.trendingReadMore": "Devamını oku",
|
||||
"marketplace.home.trendingReadMoreAbout": "{{title}} hakkında devamını oku",
|
||||
"marketplace.home.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.home.trendingView": "Görüntüle",
|
||||
"marketplace.moreFrom": "Pazar Yeri'nden daha fazlası",
|
||||
"marketplace.noPluginFound": "Eklenti bulunamadı",
|
||||
"marketplace.partnerTip": "Dify partner'ı tarafından doğrulandı",
|
||||
|
||||
@ -227,6 +227,7 @@
|
||||
"marketplace.difyMarketplace": "Dify Marketplace",
|
||||
"marketplace.discover": "Виявити",
|
||||
"marketplace.empower": "Розширюйте можливості розробки штучного інтелекту",
|
||||
"marketplace.home.creatorCenter": "Центр авторів",
|
||||
"marketplace.home.guide": "Посібник",
|
||||
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
|
||||
"marketplace.home.heroTitle": "Відкривайте. Розширюйте. Створюйте",
|
||||
@ -238,7 +239,12 @@
|
||||
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
|
||||
"marketplace.home.trendingEyebrow": "Trending Now",
|
||||
"marketplace.home.trendingPaginationLabel": "Trending pages",
|
||||
"marketplace.home.trendingPause": "Пауза",
|
||||
"marketplace.home.trendingPlay": "Відтворити",
|
||||
"marketplace.home.trendingReadMore": "Читати далі",
|
||||
"marketplace.home.trendingReadMoreAbout": "Дізнатися більше про {{title}}",
|
||||
"marketplace.home.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.home.trendingView": "Переглянути",
|
||||
"marketplace.moreFrom": "Більше від Marketplace",
|
||||
"marketplace.noPluginFound": "Плагін не знайдено",
|
||||
"marketplace.partnerTip": "Перевірено партнером Dify",
|
||||
|
||||
@ -227,6 +227,7 @@
|
||||
"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.creatorCenter": "Trung tâm nhà sáng tạo",
|
||||
"marketplace.home.guide": "Hướng dẫn",
|
||||
"marketplace.home.heroSubtitle": "Build with safer, more reliable plugins from the Dify Marketplace.",
|
||||
"marketplace.home.heroTitle": "Khám phá. Mở rộng. Xây dựng",
|
||||
@ -238,7 +239,12 @@
|
||||
"marketplace.home.trendingDescription": "Top picks by real usage, refreshed every two weeks. Ranked by actual runs across workspaces — no paid placement, no editorial picks.",
|
||||
"marketplace.home.trendingEyebrow": "Trending Now",
|
||||
"marketplace.home.trendingPaginationLabel": "Trending pages",
|
||||
"marketplace.home.trendingPause": "Tạm dừng",
|
||||
"marketplace.home.trendingPlay": "Phát",
|
||||
"marketplace.home.trendingReadMore": "Đọc thêm",
|
||||
"marketplace.home.trendingReadMoreAbout": "Đọc thêm về {{title}}",
|
||||
"marketplace.home.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.home.trendingView": "Xem",
|
||||
"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",
|
||||
|
||||
@ -227,6 +227,7 @@
|
||||
"marketplace.difyMarketplace": "Dify Marketplace",
|
||||
"marketplace.discover": "探索",
|
||||
"marketplace.empower": "助力您的 AI 开发",
|
||||
"marketplace.home.creatorCenter": "创作者中心",
|
||||
"marketplace.home.guide": "指南",
|
||||
"marketplace.home.heroSubtitle": "在 Dify Marketplace 探索更安全、更可靠的插件。",
|
||||
"marketplace.home.heroTitle": "发现。扩展。构建",
|
||||
@ -240,6 +241,8 @@
|
||||
"marketplace.home.trendingPaginationLabel": "热门推荐页码",
|
||||
"marketplace.home.trendingPause": "暂停",
|
||||
"marketplace.home.trendingPlay": "播放",
|
||||
"marketplace.home.trendingReadMore": "阅读更多",
|
||||
"marketplace.home.trendingReadMoreAbout": "阅读更多关于 {{title}} 的内容",
|
||||
"marketplace.home.trendingTitle": "大家都在安装的插件",
|
||||
"marketplace.home.trendingView": "查看",
|
||||
"marketplace.moreFrom": "来自 Marketplace 的更多内容",
|
||||
|
||||
@ -227,6 +227,7 @@
|
||||
"marketplace.difyMarketplace": "Dify Marketplace",
|
||||
"marketplace.discover": "發現",
|
||||
"marketplace.empower": "為您的 AI 開發提供支援",
|
||||
"marketplace.home.creatorCenter": "創作者中心",
|
||||
"marketplace.home.guide": "指南",
|
||||
"marketplace.home.heroSubtitle": "在 Dify Marketplace 探索更安全、更可靠的外掛程式。",
|
||||
"marketplace.home.heroTitle": "探索。擴展。建構",
|
||||
@ -240,6 +241,8 @@
|
||||
"marketplace.home.trendingPaginationLabel": "熱門推薦頁碼",
|
||||
"marketplace.home.trendingPause": "暫停",
|
||||
"marketplace.home.trendingPlay": "播放",
|
||||
"marketplace.home.trendingReadMore": "閱讀更多",
|
||||
"marketplace.home.trendingReadMoreAbout": "閱讀更多關於 {{title}} 的內容",
|
||||
"marketplace.home.trendingTitle": "大家都在安裝的外掛程式",
|
||||
"marketplace.home.trendingView": "查看",
|
||||
"marketplace.moreFrom": "來自 Marketplace 的更多內容",
|
||||
|
||||
@ -1,8 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
getMarketplaceTemplateCollectionsAndTemplates,
|
||||
searchMarketplaceTemplates,
|
||||
} from './marketplace-template-discovery'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
templateCollections: vi.fn(),
|
||||
@ -18,12 +14,20 @@ vi.mock('./client', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
// The collections helper keeps a module-level cache, so import a fresh copy
|
||||
// per test to keep them isolated.
|
||||
const importDiscovery = async () => {
|
||||
vi.resetModules()
|
||||
return import('./marketplace-template-discovery')
|
||||
}
|
||||
|
||||
describe('marketplace template discovery', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('loads each template collection and isolates a failed collection', async () => {
|
||||
const { getMarketplaceTemplateCollectionsAndTemplates } = await importDiscovery()
|
||||
mocks.templateCollections.mockResolvedValue({
|
||||
data: {
|
||||
collections: [
|
||||
@ -43,7 +47,7 @@ describe('marketplace template discovery', () => {
|
||||
})
|
||||
expect(mocks.templateCollectionTemplates).toHaveBeenNthCalledWith(1, {
|
||||
params: { collectionName: 'featured' },
|
||||
body: { limit: 100 },
|
||||
body: { limit: 24 },
|
||||
})
|
||||
expect(result.templatesByCollection).toEqual({
|
||||
featured: [{ id: 'template-1' }],
|
||||
@ -51,7 +55,51 @@ describe('marketplace template discovery', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('serves collections from the cache instead of refetching every render', async () => {
|
||||
const { getMarketplaceTemplateCollectionsAndTemplates } = await importDiscovery()
|
||||
mocks.templateCollections.mockResolvedValue({
|
||||
data: {
|
||||
collections: [{ name: 'featured', label: {}, description: {}, priority: 1 }],
|
||||
},
|
||||
})
|
||||
mocks.templateCollectionTemplates.mockResolvedValue({
|
||||
data: { templates: [{ id: 'template-1' }] },
|
||||
})
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
getMarketplaceTemplateCollectionsAndTemplates(),
|
||||
getMarketplaceTemplateCollectionsAndTemplates(),
|
||||
])
|
||||
const third = await getMarketplaceTemplateCollectionsAndTemplates()
|
||||
|
||||
expect(mocks.templateCollections).toHaveBeenCalledOnce()
|
||||
expect(mocks.templateCollectionTemplates).toHaveBeenCalledOnce()
|
||||
expect(second).toBe(first)
|
||||
expect(third).toBe(first)
|
||||
})
|
||||
|
||||
it('does not cache a failed collections fetch', async () => {
|
||||
const { getMarketplaceTemplateCollectionsAndTemplates } = await importDiscovery()
|
||||
mocks.templateCollections.mockRejectedValueOnce(new Error('Unavailable'))
|
||||
|
||||
const failed = await getMarketplaceTemplateCollectionsAndTemplates()
|
||||
expect(failed).toEqual({ collections: [], templatesByCollection: {} })
|
||||
|
||||
mocks.templateCollections.mockResolvedValue({
|
||||
data: {
|
||||
collections: [{ name: 'featured', label: {}, description: {}, priority: 1 }],
|
||||
},
|
||||
})
|
||||
mocks.templateCollectionTemplates.mockResolvedValue({
|
||||
data: { templates: [{ id: 'template-1' }] },
|
||||
})
|
||||
|
||||
const recovered = await getMarketplaceTemplateCollectionsAndTemplates()
|
||||
expect(recovered.templatesByCollection).toEqual({ featured: [{ id: 'template-1' }] })
|
||||
})
|
||||
|
||||
it('sends category searches through the Marketplace contract', async () => {
|
||||
const { searchMarketplaceTemplates } = await importDiscovery()
|
||||
mocks.templateSearch.mockResolvedValue({
|
||||
data: {
|
||||
templates: [{ id: 'template-1' }],
|
||||
@ -61,12 +109,13 @@ describe('marketplace template discovery', () => {
|
||||
|
||||
const result = await searchMarketplaceTemplates({
|
||||
category: 'marketing',
|
||||
page: 2,
|
||||
query: 'campaign',
|
||||
})
|
||||
|
||||
expect(mocks.templateSearch).toHaveBeenCalledWith({
|
||||
body: {
|
||||
page: 1,
|
||||
page: 2,
|
||||
page_size: 40,
|
||||
query: 'campaign',
|
||||
sort_by: 'usage_count',
|
||||
@ -74,6 +123,6 @@ describe('marketplace template discovery', () => {
|
||||
categories: ['marketing'],
|
||||
},
|
||||
})
|
||||
expect(result).toEqual({ templates: [{ id: 'template-1' }], total: 1 })
|
||||
expect(result).toEqual({ page: 2, templates: [{ id: 'template-1' }], total: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
@ -9,8 +9,11 @@ export type MarketplaceTemplateCollectionsResult = {
|
||||
templatesByCollection: Record<string, MarketplaceTemplate[]>
|
||||
}
|
||||
|
||||
export const TEMPLATE_SEARCH_PAGE_SIZE = 40
|
||||
|
||||
type SearchMarketplaceTemplatesOptions = {
|
||||
category: string
|
||||
page?: number
|
||||
query: string
|
||||
sortBy?: string
|
||||
sortOrder?: string
|
||||
@ -21,41 +24,85 @@ const EMPTY_COLLECTIONS_RESULT: MarketplaceTemplateCollectionsResult = {
|
||||
templatesByCollection: {},
|
||||
}
|
||||
|
||||
export async function getMarketplaceTemplateCollectionsAndTemplates(): Promise<MarketplaceTemplateCollectionsResult> {
|
||||
try {
|
||||
const response = await marketplaceClient.templateCollections({
|
||||
query: {
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
},
|
||||
})
|
||||
const collections = response.data?.collections ?? []
|
||||
const entries = await Promise.all(
|
||||
collections.map(async (collection) => {
|
||||
try {
|
||||
const collectionResponse = await marketplaceClient.templateCollectionTemplates({
|
||||
params: { collectionName: collection.name },
|
||||
body: { limit: 100 },
|
||||
})
|
||||
const COLLECTION_PREVIEW_TEMPLATE_LIMIT = 24
|
||||
const COLLECTION_FETCH_BATCH_SIZE = 5
|
||||
const COLLECTIONS_CACHE_TTL_MS = 5 * 60 * 1000
|
||||
|
||||
return [collection.name, collectionResponse.data?.templates ?? []] as const
|
||||
} catch {
|
||||
return [collection.name, []] as const
|
||||
}
|
||||
}),
|
||||
let collectionsCache: {
|
||||
expiresAt: number
|
||||
result: MarketplaceTemplateCollectionsResult
|
||||
} | null = null
|
||||
let collectionsInFlight: Promise<MarketplaceTemplateCollectionsResult> | null = null
|
||||
|
||||
async function fetchCollectionsAndTemplates(): Promise<MarketplaceTemplateCollectionsResult> {
|
||||
const response = await marketplaceClient.templateCollections({
|
||||
query: {
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
},
|
||||
})
|
||||
const collections = response.data?.collections ?? []
|
||||
const entries: (readonly [string, MarketplaceTemplate[]])[] = []
|
||||
|
||||
// Bounded fan-out: fetch collection previews in small batches instead of
|
||||
// firing one uncached request per collection all at once.
|
||||
for (
|
||||
let batchStart = 0;
|
||||
batchStart < collections.length;
|
||||
batchStart += COLLECTION_FETCH_BATCH_SIZE
|
||||
) {
|
||||
const batch = collections.slice(batchStart, batchStart + COLLECTION_FETCH_BATCH_SIZE)
|
||||
entries.push(
|
||||
...(await Promise.all(
|
||||
batch.map(async (collection) => {
|
||||
try {
|
||||
const collectionResponse = await marketplaceClient.templateCollectionTemplates({
|
||||
params: { collectionName: collection.name },
|
||||
body: { limit: COLLECTION_PREVIEW_TEMPLATE_LIMIT },
|
||||
})
|
||||
|
||||
return [collection.name, collectionResponse.data?.templates ?? []] as const
|
||||
} catch {
|
||||
return [collection.name, [] as MarketplaceTemplate[]] as const
|
||||
}
|
||||
}),
|
||||
)),
|
||||
)
|
||||
|
||||
return {
|
||||
collections,
|
||||
templatesByCollection: Object.fromEntries(entries),
|
||||
}
|
||||
} catch {
|
||||
return EMPTY_COLLECTIONS_RESULT
|
||||
}
|
||||
|
||||
return {
|
||||
collections,
|
||||
templatesByCollection: Object.fromEntries(entries),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side cached view of the template collections and their previews.
|
||||
* `marketplaceClient` opts out of the framework fetch cache (`no-store`), so
|
||||
* without this cache every server render of /templates would fan out to up to
|
||||
* 1 + N external requests. Successful results are reused for a few minutes and
|
||||
* concurrent renders share a single in-flight fetch; failures are not cached.
|
||||
*/
|
||||
export async function getMarketplaceTemplateCollectionsAndTemplates(): Promise<MarketplaceTemplateCollectionsResult> {
|
||||
if (collectionsCache && collectionsCache.expiresAt > Date.now()) return collectionsCache.result
|
||||
if (collectionsInFlight) return collectionsInFlight
|
||||
|
||||
collectionsInFlight = fetchCollectionsAndTemplates()
|
||||
.then((result) => {
|
||||
collectionsCache = { expiresAt: Date.now() + COLLECTIONS_CACHE_TTL_MS, result }
|
||||
return result
|
||||
})
|
||||
.catch(() => EMPTY_COLLECTIONS_RESULT)
|
||||
.finally(() => {
|
||||
collectionsInFlight = null
|
||||
})
|
||||
|
||||
return collectionsInFlight
|
||||
}
|
||||
|
||||
export async function searchMarketplaceTemplates({
|
||||
category,
|
||||
page = 1,
|
||||
query,
|
||||
sortBy = 'usage_count',
|
||||
sortOrder = 'DESC',
|
||||
@ -63,8 +110,8 @@ export async function searchMarketplaceTemplates({
|
||||
try {
|
||||
const response = await marketplaceClient.templateSearch({
|
||||
body: {
|
||||
page: 1,
|
||||
page_size: 40,
|
||||
page,
|
||||
page_size: TEMPLATE_SEARCH_PAGE_SIZE,
|
||||
query,
|
||||
sort_by: sortBy,
|
||||
sort_order: sortOrder,
|
||||
@ -73,11 +120,13 @@ export async function searchMarketplaceTemplates({
|
||||
})
|
||||
|
||||
return {
|
||||
page,
|
||||
templates: response.data?.templates ?? [],
|
||||
total: response.data?.total ?? 0,
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
page,
|
||||
templates: [],
|
||||
total: 0,
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user