From e7ca2735bf48ca273eef5c3dd8d85f81a8ec4f51 Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Sat, 15 Aug 2026 14:39:25 +0800 Subject: [PATCH] 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 --- .github/workflows/main-ci.yml | 61 ---------- .../workflows/marketplace-performance-e2e.yml | 4 +- .../[[...category]]/__tests__/page.spec.tsx | 62 +++++++++- .../templates/[[...category]]/page.tsx | 28 ++++- .../components/plugins/marketplace/atoms.ts | 17 ++- .../plugins/marketplace/embedded.tsx | 2 + .../home/__tests__/home-header.spec.tsx | 6 +- .../home/__tests__/home-trending.spec.tsx | 47 +++++++- .../marketplace-search-autocomplete.spec.tsx | 58 +++++++++- .../plugins/marketplace/home/home-header.tsx | 8 +- .../marketplace/home/home-trending.tsx | 13 ++- .../home/marketplace-search-autocomplete.tsx | 24 ++-- .../list/__tests__/carousel.spec.tsx | 36 +++++- .../plugins/marketplace/list/carousel.tsx | 27 ++++- .../__tests__/template-language.spec.ts | 68 +++++++++++ .../plugins/marketplace/templates/index.tsx | 95 ++++++++++++++- .../templates/template-language.ts | 45 ++++---- web/i18n/ar-TN/plugin.json | 6 + web/i18n/de-DE/plugin.json | 6 + web/i18n/en-US/plugin.json | 3 + web/i18n/es-ES/plugin.json | 6 + web/i18n/fa-IR/plugin.json | 6 + web/i18n/fr-FR/plugin.json | 6 + web/i18n/hi-IN/plugin.json | 6 + web/i18n/id-ID/plugin.json | 6 + web/i18n/it-IT/plugin.json | 6 + web/i18n/ja-JP/plugin.json | 6 + web/i18n/ko-KR/plugin.json | 6 + web/i18n/lo-LA/plugin.json | 6 + web/i18n/nl-NL/plugin.json | 6 + web/i18n/pl-PL/plugin.json | 6 + web/i18n/pt-BR/plugin.json | 6 + web/i18n/ro-RO/plugin.json | 6 + web/i18n/ru-RU/plugin.json | 6 + web/i18n/sl-SI/plugin.json | 6 + web/i18n/th-TH/plugin.json | 6 + web/i18n/tr-TR/plugin.json | 6 + web/i18n/uk-UA/plugin.json | 6 + web/i18n/vi-VN/plugin.json | 6 + web/i18n/zh-Hans/plugin.json | 3 + web/i18n/zh-Hant/plugin.json | 3 + .../marketplace-template-discovery.spec.ts | 65 +++++++++-- web/service/marketplace-template-discovery.ts | 109 +++++++++++++----- 43 files changed, 755 insertions(+), 155 deletions(-) create mode 100644 web/app/components/plugins/marketplace/templates/__tests__/template-language.spec.ts diff --git a/.github/workflows/main-ci.yml b/.github/workflows/main-ci.yml index 148eb5604f6..36dd7e05fad 100644 --- a/.github/workflows/main-ci.yml +++ b/.github/workflows/main-ci.yml @@ -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 diff --git a/.github/workflows/marketplace-performance-e2e.yml b/.github/workflows/marketplace-performance-e2e.yml index 6dceb665b21..94de24a534a 100644 --- a/.github/workflows/marketplace-performance-e2e.yml +++ b/.github/workflows/marketplace-performance-e2e.yml @@ -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 diff --git a/web/app/(commonLayout)/templates/[[...category]]/__tests__/page.spec.tsx b/web/app/(commonLayout)/templates/[[...category]]/__tests__/page.spec.tsx index b528f3b5e56..c73e16675be 100644 --- a/web/app/(commonLayout)/templates/[[...category]]/__tests__/page.spec.tsx +++ b/web/app/(commonLayout)/templates/[[...category]]/__tests__/page.spec.tsx @@ -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 }) => ( -
{`Templates catalog: ${category}:${query}`}
+ EmbeddedTemplatesMarketplace: ({ + category, + page, + query, + sortBy, + sortOrder, + view, + }: { + category: string + page: number + query: string + sortBy?: string + sortOrder?: string + view?: string + }) => ( +
+ {`Templates catalog: ${category}:${query}`} +
), })) @@ -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({ diff --git a/web/app/(commonLayout)/templates/[[...category]]/page.tsx b/web/app/(commonLayout)/templates/[[...category]]/page.tsx index 3c02c067b95..08209f2a6a3 100644 --- a/web/app/(commonLayout)/templates/[[...category]]/page.tsx +++ b/web/app/(commonLayout)/templates/[[...category]]/page.tsx @@ -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 ) diff --git a/web/app/components/plugins/marketplace/atoms.ts b/web/app/components/plugins/marketplace/atoms.ts index df9c4ec9312..76a72cd2756 100644 --- a/web/app/components/plugins/marketplace/atoms.ts +++ b/web/app/components/plugins/marketplace/atoms.ts @@ -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) diff --git a/web/app/components/plugins/marketplace/embedded.tsx b/web/app/components/plugins/marketplace/embedded.tsx index 8ec53c559d4..d1797844c7c 100644 --- a/web/app/components/plugins/marketplace/embedded.tsx +++ b/web/app/components/plugins/marketplace/embedded.tsx @@ -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: { diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-header.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-header.spec.tsx index 3528a33769a..f47b027d39c 100644 --- a/web/app/components/plugins/marketplace/home/__tests__/home-header.spec.tsx +++ b/web/app/components/plugins/marketplace/home/__tests__/home-header.spec.tsx @@ -44,7 +44,7 @@ describe('HomeHeader', () => { it('shows Creator Center before Guide', () => { render() - 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() - 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() - expect(screen.getByRole('link', { name: 'Creator Center' })).toHaveAttribute( + expect(screen.getByRole('link', { name: 'marketplace.home.creatorCenter' })).toHaveAttribute( 'href', 'https://creators.dify.ai/', ) diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx index 2e247168d98..567f7bba550 100644 --- a/web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx +++ b/web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx @@ -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() + + 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() diff --git a/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.spec.tsx index 14a00e21c4f..55b8dd0857d 100644 --- a/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.spec.tsx +++ b/web/app/components/plugins/marketplace/home/__tests__/marketplace-search-autocomplete.spec.tsx @@ -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: (value: T) => value, + useDebounce: (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 ( + + ) + } + + render(, { 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...') + }) }) diff --git a/web/app/components/plugins/marketplace/home/home-header.tsx b/web/app/components/plugins/marketplace/home/home-header.tsx index eb8ca932f9e..c2c9a7c59d6 100644 --- a/web/app/components/plugins/marketplace/home/home-header.tsx +++ b/web/app/components/plugins/marketplace/home/home-header.tsx @@ -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 ( - Creator Center + {label} ) } diff --git a/web/app/components/plugins/marketplace/home/home-trending.tsx b/web/app/components/plugins/marketplace/home/home-trending.tsx index 08281451fa9..7768d749466 100644 --- a/web/app/components/plugins/marketplace/home/home-trending.tsx +++ b/web/app/components/plugins/marketplace/home/home-trending.tsx @@ -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" >
@@ -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" > - Read more + {t(($) => $['marketplace.home.trendingReadMore'])}
@@ -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 } diff --git a/web/app/components/plugins/marketplace/home/marketplace-search-autocomplete.tsx b/web/app/components/plugins/marketplace/home/marketplace-search-autocomplete.tsx index 106ec46559d..0c29a73f070 100644 --- a/web/app/components/plugins/marketplace/home/marketplace-search-autocomplete.tsx +++ b/web/app/components/plugins/marketplace/home/marketplace-search-autocomplete.tsx @@ -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' }) diff --git a/web/app/components/plugins/marketplace/list/__tests__/carousel.spec.tsx b/web/app/components/plugins/marketplace/list/__tests__/carousel.spec.tsx index 486ef300543..f1372fe28a5 100644 --- a/web/app/components/plugins/marketplace/list/__tests__/carousel.spec.tsx +++ b/web/app/components/plugins/marketplace/list/__tests__/carousel.spec.tsx @@ -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() + 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() diff --git a/web/app/components/plugins/marketplace/list/carousel.tsx b/web/app/components/plugins/marketplace/list/carousel.tsx index 7e05a886745..c0dd5a518d0 100644 --- a/web/app/components/plugins/marketplace/list/carousel.tsx +++ b/web/app/components/plugins/marketplace/list/carousel.tsx @@ -168,6 +168,13 @@ const Carousel = ({ }: CarouselProps) => { const carouselRootRef = useRef(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 diff --git a/web/app/components/plugins/marketplace/templates/__tests__/template-language.spec.ts b/web/app/components/plugins/marketplace/templates/__tests__/template-language.spec.ts new file mode 100644 index 00000000000..f4160836db6 --- /dev/null +++ b/web/app/components/plugins/marketplace/templates/__tests__/template-language.spec.ts @@ -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']) + }) +}) diff --git a/web/app/components/plugins/marketplace/templates/index.tsx b/web/app/components/plugins/marketplace/templates/index.tsx index 96a9c9c6a2c..343c2674642 100644 --- a/web/app/components/plugins/marketplace/templates/index.tsx +++ b/web/app/components/plugins/marketplace/templates/index.tsx @@ -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 ( + + ) +} + 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({ /> } /> -
{tApp('newApp.noTemplateFound' as never)} )} + )} -
+ diff --git a/web/app/components/plugins/marketplace/templates/template-language.ts b/web/app/components/plugins/marketplace/templates/template-language.ts index fc2b293eeca..bc82613fed2 100644 --- a/web/app/components/plugins/marketplace/templates/template-language.ts +++ b/web/app/components/plugins/marketplace/templates/template-language.ts @@ -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, >(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, locale: string) { diff --git a/web/i18n/ar-TN/plugin.json b/web/i18n/ar-TN/plugin.json index abfcee07c34..d09ca304cc3 100644 --- a/web/i18n/ar-TN/plugin.json +++ b/web/i18n/ar-TN/plugin.json @@ -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", diff --git a/web/i18n/de-DE/plugin.json b/web/i18n/de-DE/plugin.json index 4808d3d0a79..71a5b08f3d9 100644 --- a/web/i18n/de-DE/plugin.json +++ b/web/i18n/de-DE/plugin.json @@ -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", diff --git a/web/i18n/en-US/plugin.json b/web/i18n/en-US/plugin.json index d00be8ff63f..73056b3e335 100644 --- a/web/i18n/en-US/plugin.json +++ b/web/i18n/en-US/plugin.json @@ -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", diff --git a/web/i18n/es-ES/plugin.json b/web/i18n/es-ES/plugin.json index 7b3bf805585..f38732b37f7 100644 --- a/web/i18n/es-ES/plugin.json +++ b/web/i18n/es-ES/plugin.json @@ -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", diff --git a/web/i18n/fa-IR/plugin.json b/web/i18n/fa-IR/plugin.json index f2c5d292b3b..ec15f1e42bd 100644 --- a/web/i18n/fa-IR/plugin.json +++ b/web/i18n/fa-IR/plugin.json @@ -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": "تأیید شده توسط یک شریک دیفی", diff --git a/web/i18n/fr-FR/plugin.json b/web/i18n/fr-FR/plugin.json index 6adaabdf09f..7e39144d228 100644 --- a/web/i18n/fr-FR/plugin.json +++ b/web/i18n/fr-FR/plugin.json @@ -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", diff --git a/web/i18n/hi-IN/plugin.json b/web/i18n/hi-IN/plugin.json index 76e77fc3bc8..385ceabe507 100644 --- a/web/i18n/hi-IN/plugin.json +++ b/web/i18n/hi-IN/plugin.json @@ -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 भागीदार द्वारा सत्यापित", diff --git a/web/i18n/id-ID/plugin.json b/web/i18n/id-ID/plugin.json index ca3b6022f7c..9b7e8d2edb5 100644 --- a/web/i18n/id-ID/plugin.json +++ b/web/i18n/id-ID/plugin.json @@ -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", diff --git a/web/i18n/it-IT/plugin.json b/web/i18n/it-IT/plugin.json index 2a21059b0ce..c4507ddc7c4 100644 --- a/web/i18n/it-IT/plugin.json +++ b/web/i18n/it-IT/plugin.json @@ -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", diff --git a/web/i18n/ja-JP/plugin.json b/web/i18n/ja-JP/plugin.json index b947ba744a0..0d24a6a3a26 100644 --- a/web/i18n/ja-JP/plugin.json +++ b/web/i18n/ja-JP/plugin.json @@ -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 のパートナーによって認証されています", diff --git a/web/i18n/ko-KR/plugin.json b/web/i18n/ko-KR/plugin.json index dadc012952b..334b3b3ddc7 100644 --- a/web/i18n/ko-KR/plugin.json +++ b/web/i18n/ko-KR/plugin.json @@ -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 파트너에 의해 확인됨", diff --git a/web/i18n/lo-LA/plugin.json b/web/i18n/lo-LA/plugin.json index e414a330d68..858e26c2b5f 100644 --- a/web/i18n/lo-LA/plugin.json +++ b/web/i18n/lo-LA/plugin.json @@ -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", diff --git a/web/i18n/nl-NL/plugin.json b/web/i18n/nl-NL/plugin.json index 876b9956376..dedc1555812 100644 --- a/web/i18n/nl-NL/plugin.json +++ b/web/i18n/nl-NL/plugin.json @@ -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", diff --git a/web/i18n/pl-PL/plugin.json b/web/i18n/pl-PL/plugin.json index 292dc084758..e1b8a569d0a 100644 --- a/web/i18n/pl-PL/plugin.json +++ b/web/i18n/pl-PL/plugin.json @@ -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", diff --git a/web/i18n/pt-BR/plugin.json b/web/i18n/pt-BR/plugin.json index cc49d792925..98d02f9d617 100644 --- a/web/i18n/pt-BR/plugin.json +++ b/web/i18n/pt-BR/plugin.json @@ -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", diff --git a/web/i18n/ro-RO/plugin.json b/web/i18n/ro-RO/plugin.json index c59bd37d137..cb44e729a0c 100644 --- a/web/i18n/ro-RO/plugin.json +++ b/web/i18n/ro-RO/plugin.json @@ -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", diff --git a/web/i18n/ru-RU/plugin.json b/web/i18n/ru-RU/plugin.json index b20334b8d41..70d9539f46c 100644 --- a/web/i18n/ru-RU/plugin.json +++ b/web/i18n/ru-RU/plugin.json @@ -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", diff --git a/web/i18n/sl-SI/plugin.json b/web/i18n/sl-SI/plugin.json index ca6a60d474b..ed75a7e9cef 100644 --- a/web/i18n/sl-SI/plugin.json +++ b/web/i18n/sl-SI/plugin.json @@ -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", diff --git a/web/i18n/th-TH/plugin.json b/web/i18n/th-TH/plugin.json index c31d34ca9ec..7166e3884fc 100644 --- a/web/i18n/th-TH/plugin.json +++ b/web/i18n/th-TH/plugin.json @@ -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", diff --git a/web/i18n/tr-TR/plugin.json b/web/i18n/tr-TR/plugin.json index dd4edf2fe55..a6069fb0936 100644 --- a/web/i18n/tr-TR/plugin.json +++ b/web/i18n/tr-TR/plugin.json @@ -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ı", diff --git a/web/i18n/uk-UA/plugin.json b/web/i18n/uk-UA/plugin.json index dabbe46e1a2..8c39af1856a 100644 --- a/web/i18n/uk-UA/plugin.json +++ b/web/i18n/uk-UA/plugin.json @@ -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", diff --git a/web/i18n/vi-VN/plugin.json b/web/i18n/vi-VN/plugin.json index 7fa4fceb908..c8bb10aac38 100644 --- a/web/i18n/vi-VN/plugin.json +++ b/web/i18n/vi-VN/plugin.json @@ -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", diff --git a/web/i18n/zh-Hans/plugin.json b/web/i18n/zh-Hans/plugin.json index 2acf2e9df7b..25fe6feb00f 100644 --- a/web/i18n/zh-Hans/plugin.json +++ b/web/i18n/zh-Hans/plugin.json @@ -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 的更多内容", diff --git a/web/i18n/zh-Hant/plugin.json b/web/i18n/zh-Hant/plugin.json index ad4caacf8a3..b5c6e57117f 100644 --- a/web/i18n/zh-Hant/plugin.json +++ b/web/i18n/zh-Hant/plugin.json @@ -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 的更多內容", diff --git a/web/service/marketplace-template-discovery.spec.ts b/web/service/marketplace-template-discovery.spec.ts index 0730b51df5b..f6940080338 100644 --- a/web/service/marketplace-template-discovery.spec.ts +++ b/web/service/marketplace-template-discovery.spec.ts @@ -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 }) }) }) diff --git a/web/service/marketplace-template-discovery.ts b/web/service/marketplace-template-discovery.ts index 49e93d75ffa..e195cc48645 100644 --- a/web/service/marketplace-template-discovery.ts +++ b/web/service/marketplace-template-discovery.ts @@ -9,8 +9,11 @@ export type MarketplaceTemplateCollectionsResult = { templatesByCollection: Record } +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 { - 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 | null = null + +async function fetchCollectionsAndTemplates(): Promise { + 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 { + 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, }