mirror of
https://github.com/langgenius/dify.git
synced 2026-09-05 00:31:19 +08:00
fix(web): address marketplace review follow-ups on assets, locale and a11y
- resample the 3MB trending background to a 97KB webp and serve a frozen recommend banner from the E2E stub so the benchmark covers the carousel - show the on-page filtered count in template search and redirect out-of-range pages to the last available page - localize the carousel controls across all 24 locales and replace the i18n type casts with typed selectors - surface Marketplace API failures with a retry action instead of rendering them as empty results - use iconify marks for the hero decorations, share the carousel page-size hook to avoid a hydration mismatch, move banner types into @dify/contracts, scope the Cmd+K shortcut to the standalone platform, point the embedded logo at /marketplace, pass resolvedTheme to the detail dialogs, and reveal the detail iframe after a loading timeout Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
3087b45017
commit
a4a57ec662
@ -6,7 +6,8 @@ import { e2eBrowser } from '../../test-env'
|
||||
// Baseline against the frozen marketplace fixture stub: the first card lands
|
||||
// around 2.3-2.6s under Fast 4G + 4x CPU throttling (dominated by the ~630KB
|
||||
// server-rendered HTML), so 4s guards regressions with headroom for slower CI
|
||||
// runners.
|
||||
// runners. The stub serves a frozen recommend banner, so the measured first
|
||||
// screen also includes the trending carousel and its background image.
|
||||
const FIRST_CARD_BUDGET_MS = 4_000
|
||||
const DOCUMENT_ELEMENT_BUDGET = 2_000
|
||||
// Hydrating the server-rendered list peaks around ~220ms on shared CI runners
|
||||
|
||||
@ -64,6 +64,33 @@ const frozenCollections = [
|
||||
makeFrozenCollection('e2e-frozen-popular', 'Frozen Popular'),
|
||||
]
|
||||
|
||||
// A frozen recommend banner keeps the trending carousel (and its decorative
|
||||
// background image) inside the measured first screen, so the benchmark covers
|
||||
// the same rendering paths as production instead of an empty banner state.
|
||||
const frozenBanners = [
|
||||
{
|
||||
id: 'e2e-frozen-banner-trending',
|
||||
title: 'Trending',
|
||||
sort: 1,
|
||||
language: 'en-US',
|
||||
style_type: 'recommend',
|
||||
content: {
|
||||
theme_type: 'hottest',
|
||||
heading: 'Frozen Trending Plugins',
|
||||
description: 'Frozen fixture banner for the performance benchmark.',
|
||||
cards: Array.from({ length: 4 }, (_, index) => ({
|
||||
item_type: 'plugin',
|
||||
item_id: `e2e-fixtures/featured-plugin-${index + 1}`,
|
||||
display_name: `Featured Plugin ${index + 1}`,
|
||||
icon_url: `/api/v1/plugins/e2e-fixtures/featured-plugin-${index + 1}/icon`,
|
||||
creator: 'e2e-fixtures',
|
||||
link: '',
|
||||
card_position: index + 1,
|
||||
})),
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const frozenCollectionPlugins: Record<string, unknown[]> = {
|
||||
'e2e-frozen-featured': Array.from({ length: 8 }, (_, index) =>
|
||||
makeFrozenPlugin(
|
||||
@ -92,7 +119,7 @@ const jsonResponse = (data: unknown): StubResponse => ({
|
||||
})
|
||||
|
||||
const resolveStubResponse = (method: string, pathname: string): StubResponse | undefined => {
|
||||
if (method === 'GET' && pathname === '/banners') return jsonResponse({ banners: [] })
|
||||
if (method === 'GET' && pathname === '/banners') return jsonResponse({ banners: frozenBanners })
|
||||
if (method === 'GET' && pathname === '/collections')
|
||||
return jsonResponse({ collections: frozenCollections })
|
||||
|
||||
|
||||
@ -192,6 +192,78 @@ export type TemplateSearchResponse = {
|
||||
|
||||
export type DownloadPluginResponse = Blob
|
||||
|
||||
// Banner payload shapes shared by the standalone marketplace and the embedded
|
||||
// console. The banners endpoint output stays `unknown` in the contract because
|
||||
// the delivery format is normalized and runtime-validated in
|
||||
// `web/app/components/plugins/marketplace/home/banners.ts`.
|
||||
export type BannerBase = {
|
||||
id: string
|
||||
title: string
|
||||
sort: number
|
||||
language: string
|
||||
}
|
||||
|
||||
export type BannerRecommendCard = {
|
||||
item_type: 'plugin' | 'template'
|
||||
item_id: string
|
||||
display_name: string
|
||||
icon_url?: string
|
||||
icon?: string
|
||||
icon_background?: string
|
||||
creator?: string
|
||||
badges?: Array<'partner' | 'verified'>
|
||||
link: string
|
||||
card_position: number
|
||||
}
|
||||
|
||||
export type BannerRecommend = BannerBase & {
|
||||
style_type: 'recommend'
|
||||
content: {
|
||||
theme_type: 'newest' | 'hottest' | 'partner'
|
||||
heading?: string
|
||||
subheadings?: string[]
|
||||
description?: string
|
||||
cards: BannerRecommendCard[]
|
||||
}
|
||||
}
|
||||
|
||||
export type BannerBlog = BannerBase & {
|
||||
style_type: 'blog'
|
||||
content: {
|
||||
blog_title: string
|
||||
subtitle?: string
|
||||
description?: string
|
||||
link: string
|
||||
link_target_type: 'blog' | 'github'
|
||||
}
|
||||
}
|
||||
|
||||
export type BannerImageContent = {
|
||||
images: {
|
||||
desktop: string
|
||||
tablet?: string
|
||||
mobile?: string
|
||||
}
|
||||
link: string
|
||||
alt_text?: string
|
||||
activity_id?: string
|
||||
}
|
||||
|
||||
export type BannerEvent = BannerBase & {
|
||||
style_type: 'event'
|
||||
content: BannerImageContent
|
||||
}
|
||||
|
||||
export type BannerAd = BannerBase & {
|
||||
style_type: 'ad'
|
||||
content: BannerImageContent & {
|
||||
partner_id?: string
|
||||
campaign_id?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type PluginBanner = BannerRecommend | BannerBlog | BannerEvent | BannerAd
|
||||
|
||||
const bannerListContract = base
|
||||
.route({
|
||||
path: '/banners',
|
||||
|
||||
@ -1,7 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="24" height="24" rx="6" fill="#DE5833"/>
|
||||
<path d="M9.1 14.05c.15-2.7 2.05-4.75 4.85-5.2 1.35-.22 2.65.2 3.25.95.3.4.2.85-.25 1.05l-1.05.45c-.5-1-1.6-1.25-2.8-.9-1.75.5-2.85 1.95-2.95 3.7-.1 1.4.8 2.6 2.15 3.1 1.4.5 2.95.05 3.75-1.05.25-.35.7-.35.95-.05.3.3.3.7 0 1.05-1.25 1.7-3.5 2.3-5.55 1.55-2.1-.75-3.4-2.6-3.25-4.65Z" fill="white"/>
|
||||
<circle cx="15.15" cy="10.55" r="1.05" fill="#1A1A1A"/>
|
||||
<circle cx="15.4" cy="10.35" r=".3" fill="white"/>
|
||||
<path d="M16.35 11.25c.8.15 1.55-.1 2-.6.15-.15 0-.4-.25-.35-.85.15-1.4.15-1.9 0-.15-.05-.3.1-.15.3.15.25.2.5.3.65Z" fill="#F5A623"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 704 B |
@ -1,6 +1,6 @@
|
||||
{
|
||||
"prefix": "custom-public",
|
||||
"lastModified": 1786630059,
|
||||
"lastModified": 1786856617,
|
||||
"icons": {
|
||||
"agent-building-blocks": {
|
||||
"body": "<path fill=\"#155AEF\" fill-rule=\"evenodd\" d=\"M8.303 1.546c.178-.045.364-.051.544-.017c.23.043.432.167.573.246l3.757 2.113c.12.067.29.156.433.289l.06.06c.12.131.21.288.267.457c.07.215.063.445.063.6V9.56c0 .146.007.36-.056.563q-.055.181-.162.338l-.075.1c-.137.163-.32.274-.442.353l-5.013 3.259c-.135.088-.33.224-.556.282a1.3 1.3 0 0 1-.543.017c-.23-.043-.433-.166-.573-.245l-3.757-2.114c-.136-.077-.34-.182-.493-.35a1.3 1.3 0 0 1-.267-.456C1.993 11.09 2 10.86 2 10.704V6.441c0-.146-.007-.36.055-.563l.043-.118a1.3 1.3 0 0 1 .195-.32l.053-.059c.128-.131.282-.225.389-.294L7.86 1.755c.122-.078.273-.165.443-.209m-4.97 9.158l.001.164l.033.02l.11.062l3.264 1.836v-1.137L3.333 9.732zm4.741.917v1.076l4.464-2.901l.098-.065l.029-.02v-.034l.001-.118v-.923zm-4.74-3.419L6.74 10.12V8.982L3.333 7.066zm4.74.752v1.076l4.592-2.985V5.969zm.51-6.08l-4.631 3.01l3.429 1.93l4.664-3.032l-3.28-1.846l-.15-.082z\" clip-rule=\"evenodd\"/>"
|
||||
@ -72,7 +72,6 @@
|
||||
},
|
||||
"common-d": {
|
||||
"body": "<g fill=\"none\"><path fill=\"#fff\" d=\"M2 1h5.943a7 7 0 1 1 0 14H2z\"/><path fill=\"url(#svgID0)\" d=\"M2 1h5.943a7 7 0 1 1 0 14H2z\"/><path fill=\"url(#svgID1)\" d=\"M7.943 8h.265v7h-.265z\"/><defs><radialGradient id=\"svgID0\" cx=\"0\" cy=\"0\" r=\"1\" gradientTransform=\"matrix(0 8.75 -8.75 0 7.943 8)\" gradientUnits=\"userSpaceOnUse\"><stop stop-color=\"#001FC2\"/><stop offset=\".711\" stop-color=\"#0667F8\" stop-opacity=\".2\"/><stop offset=\"1\" stop-color=\"#155EEF\" stop-opacity=\"0\"/></radialGradient><linearGradient id=\"svgID1\" x1=\"8.062\" x2=\"7.937\" y1=\"8.438\" y2=\"9.203\" gradientUnits=\"userSpaceOnUse\"><stop stop-color=\"#fff\" stop-opacity=\"0\"/><stop offset=\"1\" stop-color=\"#fff\"/></linearGradient></defs></g>",
|
||||
"width": 16,
|
||||
"height": 16
|
||||
},
|
||||
"common-diagonal-dividing-line": {
|
||||
@ -90,14 +89,8 @@
|
||||
"width": 24,
|
||||
"height": 24
|
||||
},
|
||||
"common-duckduckgo": {
|
||||
"body": "<g fill=\"none\"><rect width=\"24\" height=\"24\" fill=\"#DE5833\" rx=\"6\"/><path fill=\"#fff\" d=\"M9.1 14.05c.15-2.7 2.05-4.75 4.85-5.2c1.35-.22 2.65.2 3.25.95c.3.4.2.85-.25 1.05l-1.05.45c-.5-1-1.6-1.25-2.8-.9c-1.75.5-2.85 1.95-2.95 3.7c-.1 1.4.8 2.6 2.15 3.1c1.4.5 2.95.05 3.75-1.05c.25-.35.7-.35.95-.05c.3.3.3.7 0 1.05c-1.25 1.7-3.5 2.3-5.55 1.55c-2.1-.75-3.4-2.6-3.25-4.65Z\"/><circle cx=\"15.15\" cy=\"10.55\" r=\"1.05\" fill=\"#1A1A1A\"/><circle cx=\"15.4\" cy=\"10.35\" r=\".3\" fill=\"#fff\"/><path fill=\"#F5A623\" d=\"M16.35 11.25c.8.15 1.55-.1 2-.6c.15-.15 0-.4-.25-.35c-.85.15-1.4.15-1.9 0c-.15-.05-.3.1-.15.3c.15.25.2.5.3.65\"/></g>",
|
||||
"width": 24,
|
||||
"height": 24
|
||||
},
|
||||
"common-enter-key": {
|
||||
"body": "<g fill=\"#fff\"><path fill-opacity=\".12\" d=\"M0 4a4 4 0 0 1 4-4h8a4 4 0 0 1 4 4v8a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4z\"/><path d=\"M3.428 8.736V7.628h7.448q.486 0 .887-.239a1.78 1.78 0 0 0 .873-1.525q0-.486-.238-.882a1.8 1.8 0 0 0-.64-.64a1.7 1.7 0 0 0-.882-.238H10.4V3h.477q.793 0 1.44.388q.65.387 1.036 1.035q.388.648.388 1.44q0 .593-.226 1.113a2.92 2.92 0 0 1-1.525 1.538a2.8 2.8 0 0 1-1.113.222zm2.74 3.32L2.294 8.181l3.874-3.874l.762.763l-3.115 3.11l3.115 3.112z\"/></g>",
|
||||
"width": 16,
|
||||
"height": 16
|
||||
},
|
||||
"common-firecrawl": {
|
||||
@ -142,12 +135,10 @@
|
||||
},
|
||||
"common-lock": {
|
||||
"body": "<path fill=\"#155AEF\" fill-rule=\"evenodd\" d=\"M8 1.75a3.125 3.125 0 0 0-3.125 3.125v1.25C3.839 6.125 3 6.965 3 8v4.375c0 1.036.84 1.875 1.875 1.875h6.25c1.036 0 1.875-.84 1.875-1.875V8c0-1.036-.84-1.875-1.875-1.875v-1.25c0-1.726-1.4-3.125-3.125-3.125m1.875 4.375v-1.25a1.875 1.875 0 1 0-3.75 0v1.25zM8 8.625c.345 0 .625.28.625.625v1.875a.625.625 0 1 1-1.25 0V9.25c0-.345.28-.625.625-.625\" clip-rule=\"evenodd\"/>",
|
||||
"width": 16,
|
||||
"height": 16
|
||||
},
|
||||
"common-message-chat-square": {
|
||||
"body": "<g fill=\"#444CE7\"><path fill-rule=\"evenodd\" d=\"M8.774 6.667h3.785c.352 0 .655 0 .904.02c.264.021.526.069.778.197a2 2 0 0 1 .874.875c.129.252.176.514.198.777c.02.25.02.553.02.905v1.856c0 .293 0 .545-.014.754c-.015.22-.048.44-.138.657A2 2 0 0 1 14.1 13.79c-.217.09-.437.124-.657.139l-.109.005v.732a.667.667 0 0 1-1.047.548l-1.45-1.009c-.224-.155-.27-.184-.312-.203a.7.7 0 0 0-.154-.048c-.046-.009-.1-.011-.372-.011H8.774c-.351 0-.654 0-.904-.02a2 2 0 0 1-.778-.198a2 2 0 0 1-.874-.874a2 2 0 0 1-.198-.778C6 11.823 6 11.52 6 11.168V9.441c0-.352 0-.655.02-.905c.022-.263.07-.525.198-.777a2 2 0 0 1 .874-.875c.252-.128.515-.176.778-.197c.25-.02.553-.02.904-.02\" clip-rule=\"evenodd\"/><path d=\"M9.494.667H4.506c-.537 0-.98 0-1.34.029c-.375.03-.72.096-1.043.261A2.67 2.67 0 0 0 .957 2.123c-.164.323-.23.668-.26 1.042c-.03.361-.03.804-.03 1.34V7.68c0 .295 0 .513.028.706a2.67 2.67 0 0 0 2.252 2.252a.2.2 0 0 1 .09.036v1.052c0 .181 0 .36.013.503c.011.128.04.391.228.61a1 1 0 0 0 .842.345c.287-.023.493-.19.59-.273l.087-.077a4 4 0 0 1-.105-.653c-.025-.305-.025-.659-.025-.984V9.413c0-.326 0-.68.025-.985c.028-.346.098-.803.338-1.275c.32-.627.83-1.137 1.457-1.456c.471-.24.928-.31 1.274-.339c.306-.025.66-.025.985-.025h3.841c.244 0 .503 0 .746.01v-.837c0-.537 0-.98-.029-1.34c-.03-.375-.096-.72-.261-1.043A2.67 2.67 0 0 0 11.877.957c-.323-.165-.668-.23-1.042-.261c-.361-.03-.804-.03-1.34-.03\"/></g>",
|
||||
"width": 16,
|
||||
"height": 16
|
||||
},
|
||||
"common-multi-path-retrieval": {
|
||||
@ -177,7 +168,6 @@
|
||||
},
|
||||
"common-sparkles-soft-accent": {
|
||||
"body": "<g fill=\"#155AEF\"><path d=\"M12.567 1.563a.253.253 0 0 0-.247-.23a.253.253 0 0 0-.247.23c-.068.61-.241 1.028-.514 1.311c-.272.284-.673.465-1.259.535a.256.256 0 0 0-.22.258c0 .132.095.242.22.257c.576.068.987.25 1.266.535c.278.284.455.701.506 1.305c.012.134.12.236.248.236c.13 0 .237-.103.248-.237c.05-.593.226-1.02.506-1.311s.69-.476 1.259-.527a.255.255 0 0 0 .227-.258a.255.255 0 0 0-.227-.259c-.58-.053-.98-.238-1.253-.527c-.274-.29-.447-.718-.513-1.318\" opacity=\".5\"/><path d=\"M8.156 3.258a.65.65 0 0 0-.636-.591a.65.65 0 0 0-.636.59c-.174 1.567-.62 2.643-1.32 3.372S3.83 7.824 2.325 8.004a.66.66 0 0 0-.566.663c0 .34.244.624.568.662c1.479.175 2.535.64 3.253 1.374c.714.73 1.169 1.804 1.301 3.356a.65.65 0 0 0 .638.608a.65.65 0 0 0 .637-.61c.127-1.525.582-2.623 1.3-3.372c.72-.748 1.773-1.222 3.238-1.354a.657.657 0 0 0 .585-.664a.657.657 0 0 0-.584-.664c-1.49-.138-2.52-.612-3.221-1.356c-.705-.748-1.152-1.848-1.32-3.389\"/></g>",
|
||||
"width": 16,
|
||||
"height": 16
|
||||
},
|
||||
"education-triangle": {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"prefix": "custom-public",
|
||||
"name": "Dify Custom Public",
|
||||
"total": 152,
|
||||
"total": 151,
|
||||
"version": "0.0.0-private",
|
||||
"author": {
|
||||
"name": "LangGenius, Inc.",
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { PluginBanner } from '@dify/contracts/marketplace'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { PluginBanner } from '../home/banners'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { PluginBanner } from '@dify/contracts/marketplace'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { PluginBanner } from '../home/banners'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
|
||||
@ -58,7 +58,9 @@ describe('MarketplaceDetailDialog', () => {
|
||||
const frame = screen.getByTitle('Plugin A · plugin.detailPanel.operation.detail')
|
||||
expect(frame).toHaveAttribute(
|
||||
'src',
|
||||
'about:blank?plugin=dify/plugin-a&installed=true&language=en-US&source=http://localhost:3000&theme=system&view=modal',
|
||||
// resolvedTheme maps the "system" preference to the concrete value, so
|
||||
// the embedded detail page receives light/dark rather than "system".
|
||||
'about:blank?plugin=dify/plugin-a&installed=true&language=en-US&source=http://localhost:3000&theme=light&view=modal',
|
||||
)
|
||||
expect(document.querySelector('.bg-linear-to-t')).not.toBeInTheDocument()
|
||||
|
||||
|
||||
@ -20,6 +20,11 @@ type MarketplaceDetailDialogFrameProps = {
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
// The iframe load event can be delayed indefinitely on a stalled connection
|
||||
// (and cross-origin load errors are not observable), so reveal the frame after
|
||||
// this timeout instead of keeping the skeleton up forever.
|
||||
const LOADING_REVEAL_TIMEOUT_MS = 15_000
|
||||
|
||||
export default function MarketplaceDetailDialogFrame({
|
||||
open,
|
||||
src,
|
||||
@ -32,6 +37,13 @@ export default function MarketplaceDetailDialogFrame({
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
const timeout = window.setTimeout(() => setIsLoading(false), LOADING_REVEAL_TIMEOUT_MS)
|
||||
return () => window.clearTimeout(timeout)
|
||||
}, [open, src])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !onMessage) return
|
||||
|
||||
|
||||
@ -26,14 +26,16 @@ function MarketplaceDetailDialog({
|
||||
}: MarketplaceDetailDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const locale = useLocale()
|
||||
const { theme } = useTheme()
|
||||
// resolvedTheme maps the "system" preference to the concrete light/dark
|
||||
// value the marketplace page expects.
|
||||
const { resolvedTheme } = useTheme()
|
||||
const pluginLabel = plugin.label[locale] ?? plugin.label['en-US'] ?? plugin.name
|
||||
const detailLabel = t(($) => $['detailPanel.operation.detail'], { ns: 'plugin' })
|
||||
const detailURL = getPluginLinkInMarketplace(plugin, {
|
||||
installed: String(isInstalled),
|
||||
language: locale,
|
||||
source: globalThis.location?.origin,
|
||||
theme,
|
||||
theme: resolvedTheme,
|
||||
view: 'modal',
|
||||
})
|
||||
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import type { PluginBanner } from './home/banners'
|
||||
import type { PluginBanner } from '@dify/contracts/marketplace'
|
||||
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'
|
||||
@ -30,15 +29,12 @@ export function EmbeddedMarketplace({
|
||||
}: EmbeddedMarketplaceProps) {
|
||||
useResetMarketplaceSearchModeOnMount()
|
||||
const locale = useLocale()
|
||||
const input = {
|
||||
query: {
|
||||
page: 'plugins' as const,
|
||||
language: locale,
|
||||
},
|
||||
}
|
||||
const { data: banners = [] } = useQuery(
|
||||
queryOptions({
|
||||
queryKey: [...marketplaceQuery.banners.list.queryKey({ input }), locale],
|
||||
// fetchPluginBanners returns normalized PluginBanner[] rather than the
|
||||
// raw contract response, so it uses its own cache key instead of
|
||||
// impersonating the generated banners.list contract query.
|
||||
queryKey: ['marketplace-banners', locale],
|
||||
queryFn: () => fetchPluginBanners(locale),
|
||||
enabled: variant === 'home',
|
||||
initialData: locale === initialLocale ? initialBanners : undefined,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { PluginBanner } from '../banners'
|
||||
import type { PluginBanner } from '@dify/contracts/marketplace'
|
||||
import { act, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@ -27,16 +27,16 @@ vi.mock('ahooks', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) =>
|
||||
({
|
||||
'gotoAnything.searching': 'Searching...',
|
||||
'marketplace.noPluginFound': 'No integration found',
|
||||
'newApp.noTemplateFound': 'No templates found',
|
||||
})[key] ?? key,
|
||||
}),
|
||||
}))
|
||||
vi.mock('react-i18next', async () => {
|
||||
const { createReactI18nextMock } = await import('@/test/i18n-mock')
|
||||
|
||||
return createReactI18nextMock({
|
||||
clearSearch: 'Clear search',
|
||||
'gotoAnything.searching': 'Searching...',
|
||||
'marketplace.noPluginFound': 'No integration found',
|
||||
'newApp.noTemplateFound': 'No templates found',
|
||||
})
|
||||
})
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
marketplaceQuery: {
|
||||
@ -125,7 +125,7 @@ describe('MarketplaceSearchAutocomplete', () => {
|
||||
expect(container.querySelector('form')).toHaveAttribute('action', '/templates/knowledge')
|
||||
expect(container.querySelector('input[role="combobox"]')).toHaveAttribute('name', 'q')
|
||||
expect(container.querySelector('input[role="combobox"]')).toHaveAttribute('type', 'text')
|
||||
expect(container.querySelectorAll('button[aria-label="clearSearch"]')).toHaveLength(1)
|
||||
expect(container.querySelectorAll('button[aria-label="Clear search"]')).toHaveLength(1)
|
||||
expect(container.querySelector('input[type="hidden"]')).toHaveValue('en-US')
|
||||
expect(mockPluginSearch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.9 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
@ -1,75 +1,20 @@
|
||||
import type {
|
||||
BannerAd,
|
||||
BannerBase,
|
||||
BannerBlog,
|
||||
BannerEvent,
|
||||
BannerImageContent,
|
||||
BannerRecommend,
|
||||
BannerRecommendCard,
|
||||
PluginBanner,
|
||||
} from '@dify/contracts/marketplace'
|
||||
import { marketplaceClient } from '@/service/client'
|
||||
|
||||
// The banner types live in @dify/contracts/marketplace so the standalone
|
||||
// marketplace and the embedded console share one definition; this module owns
|
||||
// the runtime normalization of the untyped delivery payload.
|
||||
const MAX_CARDS_PER_PAGE = 4
|
||||
|
||||
type BannerBase = {
|
||||
id: string
|
||||
title: string
|
||||
sort: number
|
||||
language: string
|
||||
}
|
||||
|
||||
export type BannerRecommendCard = {
|
||||
item_type: 'plugin' | 'template'
|
||||
item_id: string
|
||||
display_name: string
|
||||
icon_url?: string
|
||||
icon?: string
|
||||
icon_background?: string
|
||||
creator?: string
|
||||
badges?: Array<'partner' | 'verified'>
|
||||
link: string
|
||||
card_position: number
|
||||
}
|
||||
|
||||
export type BannerRecommend = BannerBase & {
|
||||
style_type: 'recommend'
|
||||
content: {
|
||||
theme_type: 'newest' | 'hottest' | 'partner'
|
||||
heading?: string
|
||||
subheadings?: string[]
|
||||
description?: string
|
||||
cards: BannerRecommendCard[]
|
||||
}
|
||||
}
|
||||
|
||||
export type BannerBlog = BannerBase & {
|
||||
style_type: 'blog'
|
||||
content: {
|
||||
blog_title: string
|
||||
subtitle?: string
|
||||
description?: string
|
||||
link: string
|
||||
link_target_type: 'blog' | 'github'
|
||||
}
|
||||
}
|
||||
|
||||
type BannerImageContent = {
|
||||
images: {
|
||||
desktop: string
|
||||
tablet?: string
|
||||
mobile?: string
|
||||
}
|
||||
link: string
|
||||
alt_text?: string
|
||||
activity_id?: string
|
||||
}
|
||||
|
||||
export type BannerEvent = BannerBase & {
|
||||
style_type: 'event'
|
||||
content: BannerImageContent
|
||||
}
|
||||
|
||||
export type BannerAd = BannerBase & {
|
||||
style_type: 'ad'
|
||||
content: BannerImageContent & {
|
||||
partner_id?: string
|
||||
campaign_id?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type PluginBanner = BannerRecommend | BannerBlog | BannerEvent | BannerAd
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
@ -35,7 +35,9 @@ const HomeHeader = ({
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-4">
|
||||
<Link
|
||||
href="/"
|
||||
// In the embedded console "/" leaves the marketplace entirely, so
|
||||
// the brand mark points back at the marketplace home instead.
|
||||
href={isMarketplacePlatform ? '/' : '/marketplace'}
|
||||
aria-label="Dify Marketplace"
|
||||
className="flex h-full w-[141.933px] shrink-0 items-center"
|
||||
>
|
||||
|
||||
@ -13,41 +13,12 @@ type HomeHeroProps = {
|
||||
const heroDecorationIconFrameClassName =
|
||||
'absolute flex size-10 items-center justify-center overflow-hidden rounded-[10px] bg-components-panel-bg shadow-lg'
|
||||
|
||||
const DropboxIcon = () => (
|
||||
<svg className="size-10" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill="#0061FF"
|
||||
d="m7 3-5 3.2L7 9.4l5-3.2L7 3Zm10 0-5 3.2 5 3.2 5-3.2L17 3ZM7 10.6l-5 3.2L7 17l5-3.2-5-3.2Zm10 0-5 3.2 5 3.2 5-3.2-5-3.2ZM7.2 18.1l4.8 3 4.8-3-4.8-3-4.8 3Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
// The DuckDuckGo mark stays a raster export from the design frame; the other
|
||||
// brand marks come from the shared iconify collections instead of inline SVG.
|
||||
const DuckDuckGoIcon = () => (
|
||||
<img src={duckDuckGoIcon.src} alt="" className="size-10 object-cover" />
|
||||
)
|
||||
|
||||
const GmailIcon = () => (
|
||||
<svg className="size-10" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="24" height="24" rx="6" fill="white" />
|
||||
<path
|
||||
d="M4.5 7.2v9.3c0 .66.54 1.2 1.2 1.2h1.35V10.2L12 13.95l4.95-3.75v7.5h1.35c.66 0 1.2-.54 1.2-1.2V7.2c0-1.5-1.7-2.35-2.9-1.45L12 9.15 7.4 5.75C6.2 4.85 4.5 5.7 4.5 7.2Z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
<path d="M5.7 17.7h1.35V10.2L4.5 8.4v8.1c0 .66.54 1.2 1.2 1.2Z" fill="#34A853" />
|
||||
<path d="M18.3 17.7h-1.35V10.2l2.55-1.8v8.1c0 .66-.54 1.2-1.2 1.2Z" fill="#4285F4" />
|
||||
<path
|
||||
d="M19.5 7.2v-.75c0-1.5-1.7-2.35-2.9-1.45L12 9.15 7.4 5.75C6.2 4.85 4.5 5.7 4.5 6.45V8.4L12 13.95 19.5 8.4V7.2Z"
|
||||
fill="#C5221F"
|
||||
/>
|
||||
<path
|
||||
d="M4.5 8.4 12 13.95 19.5 8.4"
|
||||
stroke="#EA4335"
|
||||
strokeWidth="1.1"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
const HomeHero = ({ isMarketplacePlatform, subtitle, title }: HomeHeroProps) => {
|
||||
const { t } = useTranslation('plugin')
|
||||
|
||||
@ -61,7 +32,7 @@ const HomeHero = ({ isMarketplacePlatform, subtitle, title }: HomeHeroProps) =>
|
||||
<div className="relative flex h-[162px] w-full max-w-[726px] flex-col items-center pt-[41px]">
|
||||
<div aria-hidden className="pointer-events-none absolute inset-0 max-[879px]:hidden">
|
||||
<span className={heroDecorationIconFrameClassName} style={{ left: 99, top: 26 }}>
|
||||
<DropboxIcon />
|
||||
<span className="i-custom-public-common-dropbox size-10" />
|
||||
</span>
|
||||
<span className={heroDecorationIconFrameClassName} style={{ left: 12, top: 89 }}>
|
||||
<DuckDuckGoIcon />
|
||||
@ -70,7 +41,7 @@ const HomeHero = ({ isMarketplacePlatform, subtitle, title }: HomeHeroProps) =>
|
||||
<Github className="size-10" />
|
||||
</span>
|
||||
<span className={heroDecorationIconFrameClassName} style={{ left: 653, top: 68 }}>
|
||||
<GmailIcon />
|
||||
<span className="i-custom-public-common-gmail size-10" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
|
||||
@ -7,11 +7,23 @@ import { useTranslation } from '#i18n'
|
||||
import styles from './home-sticky.module.css'
|
||||
import MarketplacePluginSearch from './marketplace-plugin-search'
|
||||
|
||||
const HomeSearch = ({ children }: { children?: ReactNode }) => {
|
||||
type HomeSearchProps = {
|
||||
children?: ReactNode
|
||||
/**
|
||||
* Registers the global Cmd/Ctrl+K focus shortcut. The embedded console
|
||||
* already binds Mod+K to GotoAnything, so only the standalone marketplace
|
||||
* should keep this enabled.
|
||||
*/
|
||||
enableSearchShortcut?: boolean
|
||||
}
|
||||
|
||||
const HomeSearch = ({ children, enableSearchShortcut = true }: HomeSearchProps) => {
|
||||
const searchRef = useRef<HTMLDivElement>(null)
|
||||
const { t } = useTranslation('plugin')
|
||||
|
||||
useEffect(() => {
|
||||
if (!enableSearchShortcut) return
|
||||
|
||||
const handleGlobalSearchShortcut = (event: KeyboardEvent) => {
|
||||
if (event.key.toLowerCase() !== 'k' || (!event.metaKey && !event.ctrlKey)) return
|
||||
|
||||
@ -21,7 +33,7 @@ const HomeSearch = ({ children }: { children?: ReactNode }) => {
|
||||
|
||||
document.addEventListener('keydown', handleGlobalSearchShortcut)
|
||||
return () => document.removeEventListener('keydown', handleGlobalSearchShortcut)
|
||||
}, [])
|
||||
}, [enableSearchShortcut])
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import type { RefObject } from 'react'
|
||||
import type {
|
||||
BannerAd,
|
||||
BannerBlog,
|
||||
@ -8,7 +7,8 @@ import type {
|
||||
BannerRecommend,
|
||||
BannerRecommendCard,
|
||||
PluginBanner,
|
||||
} from './banners'
|
||||
} from '@dify/contracts/marketplace'
|
||||
import type { RefObject } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from '#i18n'
|
||||
@ -16,7 +16,7 @@ import Partner from '@/app/components/plugins/base/badges/partner'
|
||||
import Verified from '@/app/components/plugins/base/badges/verified'
|
||||
import { MARKETPLACE_API_PREFIX } from '@/config'
|
||||
import Link from '@/next/link'
|
||||
import background from './assets/background.jpg'
|
||||
import background from './assets/background.webp'
|
||||
import difyUpdatesArt from './assets/dify-updates-art.png'
|
||||
import styles from './home-trending.module.css'
|
||||
|
||||
@ -208,8 +208,8 @@ function TrendingRecommendationSlide({
|
||||
>
|
||||
<img
|
||||
src={background.src}
|
||||
width={3840}
|
||||
height={2160}
|
||||
width={1600}
|
||||
height={900}
|
||||
alt=""
|
||||
aria-hidden
|
||||
className="absolute top-[-173px] left-[-990px] h-[1201px] w-[2135px] max-w-none opacity-80"
|
||||
@ -534,12 +534,6 @@ function TrendingNavigation({
|
||||
if (isCurrent) return
|
||||
onSelect(index)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return
|
||||
event.preventDefault()
|
||||
if (isCurrent) return
|
||||
onSelect(index)
|
||||
}}
|
||||
className={cn(
|
||||
'absolute top-0 left-0 z-2 h-1.5 overflow-hidden rounded-full outline-hidden transition-[transform,width,background-color] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] after:absolute after:-inset-2 hover:bg-state-base-handle-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid motion-reduce:transition-none',
|
||||
isCurrent ? 'bg-transparent' : 'bg-state-base-handle',
|
||||
@ -564,11 +558,6 @@ function TrendingNavigation({
|
||||
],
|
||||
)}
|
||||
onClick={toggleAutoplay}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return
|
||||
event.preventDefault()
|
||||
toggleAutoplay()
|
||||
}}
|
||||
className="flex size-4 shrink-0 items-center justify-center rounded-full bg-state-base-handle text-text-primary outline-hidden hover:bg-state-base-handle-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
>
|
||||
{isExplicitlyPaused ? (
|
||||
@ -625,15 +614,19 @@ function HomeTrending({
|
||||
className="relative h-[200px] w-full rounded-2xl"
|
||||
data-home-trending-carousel-root
|
||||
>
|
||||
<TrendingNavigation
|
||||
banners={banners}
|
||||
selectedIndex={selectedIndex}
|
||||
carouselRootRef={carouselRootRef}
|
||||
pauseWhenOffscreen={!isMarketplacePlatform}
|
||||
onSelect={selectSlide}
|
||||
onNext={selectNextSlide}
|
||||
onPausedChange={setIsRotationPaused}
|
||||
/>
|
||||
{/* A single banner has nothing to rotate through, so skip the
|
||||
pagination/autoplay controls entirely. */}
|
||||
{banners.length > 1 && (
|
||||
<TrendingNavigation
|
||||
banners={banners}
|
||||
selectedIndex={selectedIndex}
|
||||
carouselRootRef={carouselRootRef}
|
||||
pauseWhenOffscreen={!isMarketplacePlatform}
|
||||
onSelect={selectSlide}
|
||||
onNext={selectNextSlide}
|
||||
onPausedChange={setIsRotationPaused}
|
||||
/>
|
||||
)}
|
||||
<div className="h-full overflow-hidden rounded-2xl">
|
||||
<div
|
||||
// Keep automatic rotation silent for screen readers; announce
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { PluginBanner } from '@dify/contracts/marketplace'
|
||||
import type { ActivePluginType } from '../constants'
|
||||
import type { PluginBanner } from './banners'
|
||||
import type { HomeCatalogTabLabels } from './home-catalog-tabs'
|
||||
import ListWrapper from '../list/list-wrapper'
|
||||
import HomeCatalogNavigation from './home-catalog-navigation'
|
||||
@ -47,7 +47,7 @@ const MarketplaceHome = ({
|
||||
/>
|
||||
<div className="relative flex w-full flex-col">
|
||||
<HomeHero isMarketplacePlatform={isMarketplacePlatform} />
|
||||
<HomeSearch>{search}</HomeSearch>
|
||||
<HomeSearch enableSearchShortcut={isMarketplacePlatform}>{search}</HomeSearch>
|
||||
{banners.length > 0 && (
|
||||
<>
|
||||
<div aria-hidden="true" className="h-12 shrink-0" />
|
||||
|
||||
@ -18,7 +18,7 @@ import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useTranslation } from '#i18n'
|
||||
import { renderI18nObject } from '@/i18n-config/index'
|
||||
import { marketplaceQuery } from '@/service/client'
|
||||
|
||||
@ -77,7 +77,6 @@ export function MarketplaceSearchAutocomplete({
|
||||
}: MarketplaceSearchAutocompleteProps) {
|
||||
const { t } = useTranslation()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const translate = t as (key: string, options?: Record<string, unknown>) => string
|
||||
const debouncedSearch = useDebounce(value.trim(), { wait: 300 })
|
||||
const hasQuery = Boolean(debouncedSearch)
|
||||
const showDropdown = isOpen && hasQuery
|
||||
@ -137,10 +136,17 @@ export function MarketplaceSearchAutocomplete({
|
||||
: []
|
||||
const suggestions = [...templateSuggestions, ...pluginSuggestions]
|
||||
const isSearching = isDebouncing || pluginQuery.isFetching || templateQuery.isFetching
|
||||
const emptyText =
|
||||
scope === 'templates'
|
||||
? translate('newApp.noTemplateFound', { ns: 'app' })
|
||||
: translate('marketplace.noPluginFound', { ns: 'plugin' })
|
||||
// A failed request must not read as "nothing matched"; when every source in
|
||||
// scope errored and nothing is displayable, surface a load failure instead.
|
||||
const hasLoadError =
|
||||
!isDebouncing &&
|
||||
suggestions.length === 0 &&
|
||||
((searchesPlugins && pluginQuery.isError) || (searchesTemplates && templateQuery.isError))
|
||||
const emptyText = hasLoadError
|
||||
? t(($) => $['marketplace.loadError'], { ns: 'plugin' })
|
||||
: scope === 'templates'
|
||||
? t(($) => $['newApp.noTemplateFound'], { ns: 'app' })
|
||||
: t(($) => $['marketplace.noPluginFound'], { ns: 'plugin' })
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
@ -176,7 +182,7 @@ export function MarketplaceSearchAutocomplete({
|
||||
/>
|
||||
{!!value && (
|
||||
<AutocompleteClear
|
||||
aria-label={translate('clearSearch', { ns: 'plugin', label: placeholder })}
|
||||
aria-label={t(($) => $.clearSearch, { ns: 'plugin', label: placeholder })}
|
||||
size="large"
|
||||
/>
|
||||
)}
|
||||
@ -189,7 +195,7 @@ export function MarketplaceSearchAutocomplete({
|
||||
>
|
||||
{isSearching && suggestions.length === 0 && (
|
||||
<AutocompleteStatus>
|
||||
{translate('gotoAnything.searching', { ns: 'app' })}
|
||||
{t(($) => $['gotoAnything.searching'], { ns: 'app' })}
|
||||
</AutocompleteStatus>
|
||||
)}
|
||||
<AutocompleteList<MarketplaceSuggestion>>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { PluginBanner } from '@dify/contracts/marketplace'
|
||||
import type { SearchParams } from 'nuqs'
|
||||
import type { PluginBanner } from './home/banners'
|
||||
import type { MarketplaceViewProps } from './view'
|
||||
import { getLocaleOnServer } from '@/i18n-config/server'
|
||||
import { fetchPluginBanners } from './home/banners'
|
||||
|
||||
@ -138,7 +138,9 @@ describe('Marketplace Carousel', () => {
|
||||
expect(screen.getByText('Page content 5')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Page content 3')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Go to page 4' }))
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: 'plugin.marketplace.carousel.goToPage:{"page":4}' }),
|
||||
)
|
||||
|
||||
expect(screen.getByText('Page content 3')).toBeInTheDocument()
|
||||
expect(screen.getByText('Page content 4')).toBeInTheDocument()
|
||||
@ -162,8 +164,10 @@ describe('Marketplace Carousel', () => {
|
||||
|
||||
expect(document.querySelectorAll('[data-carousel-page-mounted="true"]')).toHaveLength(5)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Scroll left' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Scroll right' }))
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: 'plugin.marketplace.carousel.scrollPrevious' }),
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'plugin.marketplace.carousel.scrollNext' }))
|
||||
|
||||
expect(mocks.api.scrollPrev).toHaveBeenCalledOnce()
|
||||
expect(mocks.api.scrollNext).toHaveBeenCalledOnce()
|
||||
|
||||
@ -276,7 +276,9 @@ describe('ListWithCollection', () => {
|
||||
)
|
||||
|
||||
expect(screen.queryByText('plugin.marketplace.viewMore')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Scroll right' })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'plugin.marketplace.carousel.scrollNext' }),
|
||||
).toBeInTheDocument()
|
||||
const carousel = screen.getByRole('region')
|
||||
const carouselViewport = carousel.querySelector('.overflow-hidden')
|
||||
const carouselContent = carouselViewport?.firstElementChild
|
||||
|
||||
@ -27,21 +27,16 @@ type CarouselProps = {
|
||||
}
|
||||
|
||||
type NavButtonProps = {
|
||||
direction: 'left' | 'right'
|
||||
disabled: boolean
|
||||
label: string
|
||||
onClick: () => void
|
||||
iconClassName: string
|
||||
}
|
||||
|
||||
const NavButton = ({ direction, disabled, onClick, iconClassName }: NavButtonProps) => (
|
||||
const NavButton = ({ label, onClick, iconClassName }: NavButtonProps) => (
|
||||
<button
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center justify-center rounded-full border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg p-2 shadow-xs backdrop-blur-[5px] transition-all hover:bg-components-button-secondary-bg-hover',
|
||||
disabled && 'cursor-not-allowed opacity-50 hover:bg-components-button-secondary-bg',
|
||||
)}
|
||||
className="flex cursor-pointer items-center justify-center rounded-full border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg p-2 shadow-xs backdrop-blur-[5px] transition-all hover:bg-components-button-secondary-bg-hover"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={`Scroll ${direction}`}
|
||||
aria-label={label}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
@ -97,21 +92,22 @@ const CarouselControls = ({
|
||||
: 'bg-components-button-secondary-border hover:bg-components-button-secondary-border-hover',
|
||||
)}
|
||||
onClick={() => scrollTo(index)}
|
||||
aria-label={`Go to page ${index + 1}`}
|
||||
aria-label={t(($) => $['marketplace.carousel.goToPage'], {
|
||||
ns: 'plugin',
|
||||
page: index + 1,
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<NavButton
|
||||
direction="left"
|
||||
disabled={totalPages <= 1}
|
||||
label={t(($) => $['marketplace.carousel.scrollPrevious'], { ns: 'plugin' })}
|
||||
onClick={scrollPrev}
|
||||
iconClassName="i-ri-arrow-left-s-line"
|
||||
/>
|
||||
<NavButton
|
||||
direction="right"
|
||||
disabled={totalPages <= 1}
|
||||
label={t(($) => $['marketplace.carousel.scrollNext'], { ns: 'plugin' })}
|
||||
onClick={scrollNext}
|
||||
iconClassName="i-ri-arrow-right-s-line"
|
||||
/>
|
||||
|
||||
@ -10,7 +10,8 @@ import { useMarketplaceMoreClick } from '../atoms'
|
||||
import { buildCarouselPages } from '../utils'
|
||||
import CardWrapper from './card-wrapper'
|
||||
import Carousel from './carousel'
|
||||
import { CAROUSEL_BREAKPOINTS, CAROUSEL_PAGE_SIZE, GRID_CLASS } from './collection-constants'
|
||||
import { GRID_CLASS } from './collection-constants'
|
||||
import { useCarouselItemsPerPage } from './use-carousel-items-per-page'
|
||||
|
||||
const BECOME_PARTNER_URL = 'https://share-na2.hsforms.com/1NiS4r9lsSqGcuNBB77DeEQ40s9fk'
|
||||
const PARTNERS_COLLECTION_NAMES = new Set(['partners', 'partner-template', 'Partner Template'])
|
||||
@ -18,17 +19,6 @@ const COLLECTION_PRELOAD_MARGIN = '320px 0px'
|
||||
const COLLECTION_INTERSECTION_THRESHOLD = 0.01
|
||||
const MAX_PLACEHOLDER_CARDS = 8
|
||||
|
||||
const getViewportWidth = () =>
|
||||
typeof window === 'undefined' ? CAROUSEL_BREAKPOINTS.xl : window.innerWidth
|
||||
|
||||
const getCarouselItemsPerPage = (viewportWidth: number) => {
|
||||
if (viewportWidth >= CAROUSEL_BREAKPOINTS.xl) return CAROUSEL_PAGE_SIZE.xl
|
||||
if (viewportWidth >= CAROUSEL_BREAKPOINTS.lg) return CAROUSEL_PAGE_SIZE.lg
|
||||
if (viewportWidth >= CAROUSEL_BREAKPOINTS.sm) return CAROUSEL_PAGE_SIZE.sm
|
||||
|
||||
return CAROUSEL_PAGE_SIZE.base
|
||||
}
|
||||
|
||||
type ListWithCollectionProps = {
|
||||
marketplaceCollections: MarketplaceCollection[]
|
||||
marketplaceCollectionPluginsMap: Record<string, Plugin[]>
|
||||
@ -269,16 +259,7 @@ const ListWithCollection = ({
|
||||
}: ListWithCollectionProps) => {
|
||||
const defaultOnMoreClick = useMarketplaceMoreClick()
|
||||
const handleMoreClick = onCollectionMoreClick ?? defaultOnMoreClick
|
||||
const [viewportWidth, setViewportWidth] = useState(getViewportWidth)
|
||||
const itemsPerPage = useMemo(() => getCarouselItemsPerPage(viewportWidth), [viewportWidth])
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => setViewportWidth(window.innerWidth)
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}, [])
|
||||
const itemsPerPage = useCarouselItemsPerPage()
|
||||
|
||||
return marketplaceCollections
|
||||
.filter((collection) => marketplaceCollectionPluginsMap[collection.name]?.length)
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
'use client'
|
||||
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { CAROUSEL_BREAKPOINTS, CAROUSEL_PAGE_SIZE } from './collection-constants'
|
||||
|
||||
const subscribeToViewport = (onStoreChange: () => void) => {
|
||||
globalThis.window?.addEventListener('resize', onStoreChange)
|
||||
|
||||
return () => globalThis.window?.removeEventListener('resize', onStoreChange)
|
||||
}
|
||||
|
||||
const getViewportWidth = () => globalThis.window?.innerWidth ?? CAROUSEL_BREAKPOINTS.xl
|
||||
const getServerViewportWidth = () => CAROUSEL_BREAKPOINTS.xl
|
||||
|
||||
export function getCarouselItemsPerPage(viewportWidth: number) {
|
||||
if (viewportWidth >= CAROUSEL_BREAKPOINTS.xl) return CAROUSEL_PAGE_SIZE.xl
|
||||
if (viewportWidth >= CAROUSEL_BREAKPOINTS.lg) return CAROUSEL_PAGE_SIZE.lg
|
||||
if (viewportWidth >= CAROUSEL_BREAKPOINTS.sm) return CAROUSEL_PAGE_SIZE.sm
|
||||
|
||||
return CAROUSEL_PAGE_SIZE.base
|
||||
}
|
||||
|
||||
/**
|
||||
* Viewport-derived carousel page size. useSyncExternalStore keeps the
|
||||
* hydration render on the server snapshot (xl) and applies the real viewport
|
||||
* in a follow-up render, so narrow viewports do not trigger a hydration
|
||||
* mismatch against the server-rendered markup.
|
||||
*/
|
||||
export function useCarouselItemsPerPage() {
|
||||
const viewportWidth = useSyncExternalStore(
|
||||
subscribeToViewport,
|
||||
getViewportWidth,
|
||||
getServerViewportWidth,
|
||||
)
|
||||
|
||||
return getCarouselItemsPerPage(viewportWidth)
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vite-plus/test'
|
||||
import { filterTemplatesForLocale } from '../template-language'
|
||||
import { filterTemplatesForLocale, getTemplateCollectionText } from '../template-language'
|
||||
|
||||
const template = (id: string, preferredLanguages?: string[]) => ({
|
||||
id,
|
||||
@ -66,3 +66,22 @@ describe('filterTemplatesForLocale', () => {
|
||||
expect(ids(filterTemplatesForLocale(templates, 'zh_Hans'))).toEqual(['zh'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTemplateCollectionText', () => {
|
||||
it('uses the matching collection translation and falls back to English', () => {
|
||||
const label = {
|
||||
en_US: 'Featured',
|
||||
zh_Hans: '精选',
|
||||
zh_Hant: '精選',
|
||||
ja_JP: '注目',
|
||||
}
|
||||
|
||||
expect(getTemplateCollectionText(label, 'zh-Hant')).toBe('精選')
|
||||
expect(getTemplateCollectionText(label, 'de-DE')).toBe('Featured')
|
||||
})
|
||||
|
||||
it('falls back to the first available translation when English is missing', () => {
|
||||
expect(getTemplateCollectionText({ ja_JP: '注目' }, 'de-DE')).toBe('注目')
|
||||
expect(getTemplateCollectionText({}, 'de-DE')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
@ -5,6 +5,7 @@ import { cn } from '@langgenius/dify-ui/cn'
|
||||
import AccountSection from '@/app/components/main-nav/components/account-section'
|
||||
import { getTranslation } from '@/i18n-config/server'
|
||||
import Link from '@/next/link'
|
||||
import { redirect } from '@/next/navigation'
|
||||
import {
|
||||
getMarketplaceTemplateCollectionsAndTemplates,
|
||||
searchMarketplaceTemplates,
|
||||
@ -110,6 +111,55 @@ const PAGE_LINK_CLASS =
|
||||
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'
|
||||
|
||||
type TemplatesHrefOptions = {
|
||||
category: TemplateCategory
|
||||
page?: number
|
||||
query?: string
|
||||
sortBy?: string
|
||||
sortOrder?: string
|
||||
view?: string
|
||||
}
|
||||
|
||||
function buildTemplatesHref({
|
||||
category,
|
||||
page = 1,
|
||||
query,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
view,
|
||||
}: TemplatesHrefOptions) {
|
||||
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 (page > 1) searchParams.set('page', String(page))
|
||||
const queryString = searchParams.toString()
|
||||
const basePath = category === 'all' ? '/templates' : `/templates/${category}`
|
||||
return queryString ? `${basePath}?${queryString}` : basePath
|
||||
}
|
||||
|
||||
// The retry link is a plain anchor on purpose: a full navigation re-runs the
|
||||
// failed (and uncached) server fetch instead of reusing the router cache.
|
||||
function LoadErrorState({
|
||||
message,
|
||||
retryHref,
|
||||
retryLabel,
|
||||
}: {
|
||||
message: string
|
||||
retryHref: string
|
||||
retryLabel: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-60 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-divider-regular text-sm text-text-tertiary">
|
||||
<span>{message}</span>
|
||||
<a href={retryHref} className={PAGE_LINK_CLASS}>
|
||||
{retryLabel}
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Server-rendered pagination: plain links keep the search results reachable
|
||||
// beyond the first page without any client-side state.
|
||||
function TemplatePagination({
|
||||
@ -137,17 +187,8 @@ function TemplatePagination({
|
||||
}) {
|
||||
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
|
||||
}
|
||||
const buildHref = (targetPage: number) =>
|
||||
buildTemplatesHref({ category, page: targetPage, query, sortBy, sortOrder, view })
|
||||
|
||||
return (
|
||||
<nav aria-label={navigationLabel} className="mt-6 flex items-center justify-center gap-3 pb-4">
|
||||
@ -215,16 +256,32 @@ export async function EmbeddedTemplatesMarketplace({
|
||||
fetchPluginBanners(locale).catch(() => []),
|
||||
])
|
||||
const categoryLabels: TemplateCategoryLabels = {
|
||||
all: tPlugin('category.all' as never),
|
||||
marketing: tApp('marketplace.template.category.marketing' as never),
|
||||
sales: tApp('marketplace.template.category.sales' as never),
|
||||
support: tApp('marketplace.template.category.support' as never),
|
||||
operations: tApp('marketplace.template.category.operations' as never),
|
||||
it: tApp('marketplace.template.category.it' as never),
|
||||
knowledge: tApp('marketplace.template.category.knowledge' as never),
|
||||
design: tApp('marketplace.template.category.design' as never),
|
||||
others: tPluginTags('tags.other' as never),
|
||||
all: tPlugin(($) => $['category.all'], { ns: 'plugin' }),
|
||||
marketing: tApp(($) => $['marketplace.template.category.marketing'], { ns: 'app' }),
|
||||
sales: tApp(($) => $['marketplace.template.category.sales'], { ns: 'app' }),
|
||||
support: tApp(($) => $['marketplace.template.category.support'], { ns: 'app' }),
|
||||
operations: tApp(($) => $['marketplace.template.category.operations'], { ns: 'app' }),
|
||||
it: tApp(($) => $['marketplace.template.category.it'], { ns: 'app' }),
|
||||
knowledge: tApp(($) => $['marketplace.template.category.knowledge'], { ns: 'app' }),
|
||||
design: tApp(($) => $['marketplace.template.category.design'], { ns: 'app' }),
|
||||
others: tPluginTags(($) => $['tags.other'], { ns: 'pluginTags' }),
|
||||
}
|
||||
const pageCount = Math.ceil((searchResult?.total ?? 0) / TEMPLATE_SEARCH_PAGE_SIZE)
|
||||
// An out-of-range ?page= would render a misleading empty state; send the
|
||||
// visitor to the last page that actually exists instead.
|
||||
if (searchResult?.ok && searchResult.total > 0 && page > pageCount) {
|
||||
redirect(
|
||||
buildTemplatesHref({
|
||||
category,
|
||||
page: pageCount,
|
||||
query: normalizedQuery,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
view,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const templates = filterTemplatesForLocale(searchResult?.templates ?? [], locale)
|
||||
const hasVisibleCollections =
|
||||
collectionsResult?.collections.some(
|
||||
@ -234,9 +291,29 @@ export async function EmbeddedTemplatesMarketplace({
|
||||
locale,
|
||||
).length > 0,
|
||||
) ?? false
|
||||
const pluginsLabel = tPlugin('marketplace.home.plugins' as never)
|
||||
const templatesLabel = tPlugin('marketplace.home.templates' as never)
|
||||
const partnerText = tPlugin('marketplace.partnerTip' as never)
|
||||
const pluginsLabel = tPlugin(($) => $['marketplace.home.plugins'], { ns: 'plugin' })
|
||||
const templatesLabel = tPlugin(($) => $['marketplace.home.templates'], { ns: 'plugin' })
|
||||
const partnerText = tPlugin(($) => $['marketplace.partnerTip'], { ns: 'plugin' })
|
||||
const loadFailed = collectionsResult
|
||||
? !collectionsResult.ok
|
||||
: searchResult
|
||||
? !searchResult.ok
|
||||
: false
|
||||
const currentHref = buildTemplatesHref({
|
||||
category,
|
||||
page,
|
||||
query: normalizedQuery,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
view,
|
||||
})
|
||||
const loadErrorState = (
|
||||
<LoadErrorState
|
||||
message={tPlugin(($) => $['marketplace.loadError'], { ns: 'plugin' })}
|
||||
retryHref={currentHref}
|
||||
retryLabel={tCommon(($) => $['operation.retry'], { ns: 'common' })}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<HomeStickyStateProvider>
|
||||
@ -255,15 +332,15 @@ export async function EmbeddedTemplatesMarketplace({
|
||||
<HomeHero
|
||||
isMarketplacePlatform={false}
|
||||
title={templatesLabel}
|
||||
subtitle={tExplore('apps.description' as never)}
|
||||
subtitle={tExplore(($) => $['apps.description'], { ns: 'explore' })}
|
||||
/>
|
||||
<HomeSearch>
|
||||
<HomeSearch enableSearchShortcut={false}>
|
||||
<MarketplaceSearchForm
|
||||
action={category === 'all' ? '/templates' : `/templates/${category}`}
|
||||
category={category}
|
||||
className="w-full"
|
||||
locale={locale}
|
||||
placeholder={tApp('newAppFromTemplate.searchAllTemplate' as never)}
|
||||
placeholder={tApp(($) => $['newAppFromTemplate.searchAllTemplate'], { ns: 'app' })}
|
||||
query={query}
|
||||
scope="templates"
|
||||
/>
|
||||
@ -285,7 +362,7 @@ export async function EmbeddedTemplatesMarketplace({
|
||||
catalogCategories={
|
||||
<TemplateCategoryNavigation
|
||||
activeCategory={category}
|
||||
ariaLabel={tPlugin('allCategories' as never)}
|
||||
ariaLabel={tPlugin(($) => $.allCategories, { ns: 'plugin' })}
|
||||
labels={categoryLabels}
|
||||
query={query}
|
||||
/>
|
||||
@ -299,36 +376,43 @@ export async function EmbeddedTemplatesMarketplace({
|
||||
styles.catalogContent,
|
||||
)}
|
||||
>
|
||||
{collectionsResult ? (
|
||||
{loadFailed ? (
|
||||
loadErrorState
|
||||
) : collectionsResult ? (
|
||||
hasVisibleCollections ? (
|
||||
<TemplateCollectionList
|
||||
becomePartnerText={tPlugin('marketplace.becomePartner' as never)}
|
||||
becomePartnerText={tPlugin(($) => $['marketplace.becomePartner'], {
|
||||
ns: 'plugin',
|
||||
})}
|
||||
collections={collectionsResult.collections}
|
||||
locale={locale}
|
||||
partnerText={partnerText}
|
||||
templatesByCollection={collectionsResult.templatesByCollection}
|
||||
viewMoreText={tPlugin('marketplace.viewMore' as never)}
|
||||
viewMoreText={tPlugin(($) => $['marketplace.viewMore'], { ns: 'plugin' })}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState>{tApp('newApp.noTemplateFound' as never)}</EmptyState>
|
||||
<EmptyState>{tApp(($) => $['newApp.noTemplateFound'], { ns: 'app' })}</EmptyState>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{/* The locale filter runs after pagination, so the API total
|
||||
does not describe what is on screen; show the number of
|
||||
templates actually rendered on this page instead. */}
|
||||
<div className="mb-5 text-right text-sm text-text-tertiary">
|
||||
{tExplore('apps.resultNum' as never, { num: searchResult?.total ?? 0 })}
|
||||
{tExplore(($) => $['apps.resultNum'], { ns: 'explore', num: templates.length })}
|
||||
</div>
|
||||
{templates.length > 0 ? (
|
||||
<TemplateGrid partnerText={partnerText} templates={templates} />
|
||||
) : (
|
||||
<EmptyState>{tApp('newApp.noTemplateFound' as never)}</EmptyState>
|
||||
<EmptyState>{tApp(($) => $['newApp.noTemplateFound'], { ns: 'app' })}</EmptyState>
|
||||
)}
|
||||
<TemplatePagination
|
||||
category={category}
|
||||
navigationLabel={tCommon('pagination.pageNumber' as never)}
|
||||
nextLabel={tCommon('pagination.next' as never)}
|
||||
navigationLabel={tCommon(($) => $['pagination.pageNumber'], { ns: 'common' })}
|
||||
nextLabel={tCommon(($) => $['pagination.next'], { ns: 'common' })}
|
||||
page={page}
|
||||
pageCount={Math.ceil((searchResult?.total ?? 0) / TEMPLATE_SEARCH_PAGE_SIZE)}
|
||||
previousLabel={tCommon('pagination.previous' as never)}
|
||||
pageCount={pageCount}
|
||||
previousLabel={tCommon(($) => $['pagination.previous'], { ns: 'common' })}
|
||||
query={normalizedQuery}
|
||||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
|
||||
@ -5,10 +5,10 @@ import type {
|
||||
MarketplaceTemplateCollection,
|
||||
} from '@dify/contracts/marketplace'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import Link from '@/next/link'
|
||||
import Carousel from '../list/carousel'
|
||||
import { CAROUSEL_BREAKPOINTS, CAROUSEL_PAGE_SIZE, GRID_CLASS } from '../list/collection-constants'
|
||||
import { GRID_CLASS } from '../list/collection-constants'
|
||||
import { useCarouselItemsPerPage } from '../list/use-carousel-items-per-page'
|
||||
import TemplateCard from './template-card'
|
||||
import { filterTemplatesForLocale, getTemplateCollectionText } from './template-language'
|
||||
|
||||
@ -24,23 +24,6 @@ type TemplateCollectionListProps = {
|
||||
viewMoreText: string
|
||||
}
|
||||
|
||||
function subscribeToViewport(onStoreChange: () => void) {
|
||||
globalThis.window?.addEventListener('resize', onStoreChange)
|
||||
|
||||
return () => globalThis.window?.removeEventListener('resize', onStoreChange)
|
||||
}
|
||||
|
||||
const getViewportWidth = () => globalThis.window?.innerWidth ?? CAROUSEL_BREAKPOINTS.xl
|
||||
const getServerViewportWidth = () => CAROUSEL_BREAKPOINTS.xl
|
||||
|
||||
function getCarouselItemsPerPage(viewportWidth: number) {
|
||||
if (viewportWidth >= CAROUSEL_BREAKPOINTS.xl) return CAROUSEL_PAGE_SIZE.xl
|
||||
if (viewportWidth >= CAROUSEL_BREAKPOINTS.lg) return CAROUSEL_PAGE_SIZE.lg
|
||||
if (viewportWidth >= CAROUSEL_BREAKPOINTS.sm) return CAROUSEL_PAGE_SIZE.sm
|
||||
|
||||
return CAROUSEL_PAGE_SIZE.base
|
||||
}
|
||||
|
||||
function getViewMoreHref(collection: MarketplaceTemplateCollection) {
|
||||
const searchParams = new URLSearchParams({ view: 'search' })
|
||||
const collectionSearch = collection.search_params
|
||||
@ -60,12 +43,7 @@ export default function TemplateCollectionList({
|
||||
templatesByCollection,
|
||||
viewMoreText,
|
||||
}: TemplateCollectionListProps) {
|
||||
const viewportWidth = useSyncExternalStore(
|
||||
subscribeToViewport,
|
||||
getViewportWidth,
|
||||
getServerViewportWidth,
|
||||
)
|
||||
const itemsPerPage = getCarouselItemsPerPage(viewportWidth)
|
||||
const itemsPerPage = useCarouselItemsPerPage()
|
||||
|
||||
return collections.map((collection) => {
|
||||
const templates = filterTemplatesForLocale(templatesByCollection[collection.name] ?? [], locale)
|
||||
|
||||
@ -24,12 +24,14 @@ export default function TemplateDetailDialog({
|
||||
}: TemplateDetailDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const locale = useLocale()
|
||||
const { theme } = useTheme()
|
||||
// resolvedTheme maps the "system" preference to the concrete light/dark
|
||||
// value the marketplace page expects.
|
||||
const { resolvedTheme } = useTheme()
|
||||
const detailLabel = t(($) => $['detailPanel.operation.detail'], { ns: 'plugin' })
|
||||
const detailURL = getTemplateLinkInMarketplace(template, {
|
||||
language: locale,
|
||||
source: globalThis.location?.origin,
|
||||
theme,
|
||||
theme: resolvedTheme,
|
||||
view: 'modal',
|
||||
})
|
||||
const handleMessage = useCallback(
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { PluginBanner } from '@dify/contracts/marketplace'
|
||||
import type { ActivePluginType } from './constants'
|
||||
import type { PluginBanner } from './home/banners'
|
||||
import type { HomeCatalogTabLabels } from './home/home-catalog-tabs'
|
||||
import { PluginInstallPermissionProviderGuard } from '@/app/components/plugins/install-plugin/components/plugin-install-permission-provider'
|
||||
import Description from './description'
|
||||
|
||||
@ -224,6 +224,9 @@
|
||||
"marketplace.allPlugins": "جميع الإضافات",
|
||||
"marketplace.and": "و",
|
||||
"marketplace.becomePartner": "كن شريكًا",
|
||||
"marketplace.carousel.goToPage": "الانتقال إلى الصفحة {{page}}",
|
||||
"marketplace.carousel.scrollNext": "الصفحة التالية",
|
||||
"marketplace.carousel.scrollPrevious": "الصفحة السابقة",
|
||||
"marketplace.difyMarketplace": "سوق Dify",
|
||||
"marketplace.discover": "اكتشف",
|
||||
"marketplace.empower": "تمكين تطوير الذكاء الاصطناعي الخاص بك",
|
||||
@ -245,6 +248,7 @@
|
||||
"marketplace.home.trendingReadMoreAbout": "اقرأ المزيد عن {{title}}",
|
||||
"marketplace.home.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.home.trendingView": "عرض",
|
||||
"marketplace.loadError": "فشل التحميل. يرجى المحاولة مرة أخرى.",
|
||||
"marketplace.moreFrom": "المزيد من السوق",
|
||||
"marketplace.noPluginFound": "لم يتم العثور على إضافة",
|
||||
"marketplace.partnerTip": "تم التحقق بواسطة شريك Dify",
|
||||
|
||||
@ -224,6 +224,9 @@
|
||||
"marketplace.allPlugins": "Alle Plugins",
|
||||
"marketplace.and": "und",
|
||||
"marketplace.becomePartner": "Partner werden",
|
||||
"marketplace.carousel.goToPage": "Zu Seite {{page}} wechseln",
|
||||
"marketplace.carousel.scrollNext": "Nächste Seite",
|
||||
"marketplace.carousel.scrollPrevious": "Vorherige Seite",
|
||||
"marketplace.difyMarketplace": "Dify Marktplatz",
|
||||
"marketplace.discover": "Entdecken",
|
||||
"marketplace.empower": "Unterstützen Sie Ihre KI-Entwicklung",
|
||||
@ -245,6 +248,7 @@
|
||||
"marketplace.home.trendingReadMoreAbout": "Mehr erfahren über {{title}}",
|
||||
"marketplace.home.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.home.trendingView": "Ansehen",
|
||||
"marketplace.loadError": "Laden fehlgeschlagen. Bitte versuchen Sie es erneut.",
|
||||
"marketplace.moreFrom": "Mehr aus dem Marketplace",
|
||||
"marketplace.noPluginFound": "Kein Plugin gefunden",
|
||||
"marketplace.partnerTip": "Von einem Dify-Partner verifiziert",
|
||||
|
||||
@ -224,6 +224,9 @@
|
||||
"marketplace.allPlugins": "All integrations",
|
||||
"marketplace.and": "and",
|
||||
"marketplace.becomePartner": "Become a Partner",
|
||||
"marketplace.carousel.goToPage": "Go to page {{page}}",
|
||||
"marketplace.carousel.scrollNext": "Next page",
|
||||
"marketplace.carousel.scrollPrevious": "Previous page",
|
||||
"marketplace.difyMarketplace": "Dify Marketplace",
|
||||
"marketplace.discover": "Discover",
|
||||
"marketplace.empower": "Empower your AI development",
|
||||
@ -245,6 +248,7 @@
|
||||
"marketplace.home.trendingReadMoreAbout": "Read more about {{title}}",
|
||||
"marketplace.home.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.home.trendingView": "View",
|
||||
"marketplace.loadError": "Failed to load. Please try again.",
|
||||
"marketplace.moreFrom": "More from Marketplace",
|
||||
"marketplace.noPluginFound": "No integration found",
|
||||
"marketplace.partnerTip": "Verified by a Dify partner",
|
||||
|
||||
@ -224,6 +224,9 @@
|
||||
"marketplace.allPlugins": "Todas las integraciones",
|
||||
"marketplace.and": "y",
|
||||
"marketplace.becomePartner": "Conviértete en socio",
|
||||
"marketplace.carousel.goToPage": "Ir a la página {{page}}",
|
||||
"marketplace.carousel.scrollNext": "Página siguiente",
|
||||
"marketplace.carousel.scrollPrevious": "Página anterior",
|
||||
"marketplace.difyMarketplace": "Mercado de Dify",
|
||||
"marketplace.discover": "Descubrir",
|
||||
"marketplace.empower": "Potencie su desarrollo de IA",
|
||||
@ -245,6 +248,7 @@
|
||||
"marketplace.home.trendingReadMoreAbout": "Leer más sobre {{title}}",
|
||||
"marketplace.home.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.home.trendingView": "Ver",
|
||||
"marketplace.loadError": "Error al cargar. Inténtalo de nuevo.",
|
||||
"marketplace.moreFrom": "Más de Marketplace",
|
||||
"marketplace.noPluginFound": "No se ha encontrado ninguna integración",
|
||||
"marketplace.partnerTip": "Verificado por un socio de Dify",
|
||||
|
||||
@ -224,6 +224,9 @@
|
||||
"marketplace.allPlugins": "همه افزونهها",
|
||||
"marketplace.and": "و",
|
||||
"marketplace.becomePartner": "شریک شوید",
|
||||
"marketplace.carousel.goToPage": "رفتن به صفحه {{page}}",
|
||||
"marketplace.carousel.scrollNext": "صفحه بعدی",
|
||||
"marketplace.carousel.scrollPrevious": "صفحه قبلی",
|
||||
"marketplace.difyMarketplace": "بازار دیفی",
|
||||
"marketplace.discover": "کشف",
|
||||
"marketplace.empower": "توسعه هوش مصنوعی خود را توانمند کنید",
|
||||
@ -245,6 +248,7 @@
|
||||
"marketplace.home.trendingReadMoreAbout": "ادامه مطلب درباره {{title}}",
|
||||
"marketplace.home.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.home.trendingView": "مشاهده",
|
||||
"marketplace.loadError": "بارگیری ناموفق بود. لطفاً دوباره تلاش کنید.",
|
||||
"marketplace.moreFrom": "اطلاعات بیشتر از Marketplace",
|
||||
"marketplace.noPluginFound": "هیچ افزونهای یافت نشد",
|
||||
"marketplace.partnerTip": "تأیید شده توسط یک شریک دیفی",
|
||||
|
||||
@ -224,6 +224,9 @@
|
||||
"marketplace.allPlugins": "Toutes les intégrations",
|
||||
"marketplace.and": "et",
|
||||
"marketplace.becomePartner": "Devenir partenaire",
|
||||
"marketplace.carousel.goToPage": "Aller à la page {{page}}",
|
||||
"marketplace.carousel.scrollNext": "Page suivante",
|
||||
"marketplace.carousel.scrollPrevious": "Page précédente",
|
||||
"marketplace.difyMarketplace": "Marché Dify",
|
||||
"marketplace.discover": "Découvrir",
|
||||
"marketplace.empower": "Renforcez le développement de votre IA",
|
||||
@ -245,6 +248,7 @@
|
||||
"marketplace.home.trendingReadMoreAbout": "En savoir plus sur {{title}}",
|
||||
"marketplace.home.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.home.trendingView": "Voir",
|
||||
"marketplace.loadError": "Échec du chargement. Veuillez réessayer.",
|
||||
"marketplace.moreFrom": "Plus de Marketplace",
|
||||
"marketplace.noPluginFound": "Aucune intégration trouvée",
|
||||
"marketplace.partnerTip": "Vérifié par un partenaire Dify",
|
||||
|
||||
@ -224,6 +224,9 @@
|
||||
"marketplace.allPlugins": "सभी इंटीग्रेशन",
|
||||
"marketplace.and": "और",
|
||||
"marketplace.becomePartner": "भागीदार बनें",
|
||||
"marketplace.carousel.goToPage": "पृष्ठ {{page}} पर जाएं",
|
||||
"marketplace.carousel.scrollNext": "अगला पृष्ठ",
|
||||
"marketplace.carousel.scrollPrevious": "पिछला पृष्ठ",
|
||||
"marketplace.difyMarketplace": "डिफाई मार्केटप्लेस",
|
||||
"marketplace.discover": "खोजें",
|
||||
"marketplace.empower": "अपने एआई विकास को सशक्त बनाएं",
|
||||
@ -245,6 +248,7 @@
|
||||
"marketplace.home.trendingReadMoreAbout": "{{title}} के बारे में और पढ़ें",
|
||||
"marketplace.home.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.home.trendingView": "देखें",
|
||||
"marketplace.loadError": "लोड नहीं हो सका। कृपया पुनः प्रयास करें।",
|
||||
"marketplace.moreFrom": "मार्केटप्लेस से अधिक",
|
||||
"marketplace.noPluginFound": "कोई इंटीग्रेशन नहीं मिला",
|
||||
"marketplace.partnerTip": "Dify भागीदार द्वारा सत्यापित",
|
||||
|
||||
@ -224,6 +224,9 @@
|
||||
"marketplace.allPlugins": "Semua integrasi",
|
||||
"marketplace.and": "dan",
|
||||
"marketplace.becomePartner": "Menjadi Partner",
|
||||
"marketplace.carousel.goToPage": "Buka halaman {{page}}",
|
||||
"marketplace.carousel.scrollNext": "Halaman berikutnya",
|
||||
"marketplace.carousel.scrollPrevious": "Halaman sebelumnya",
|
||||
"marketplace.difyMarketplace": "Dify Marketplace",
|
||||
"marketplace.discover": "Menemukan",
|
||||
"marketplace.empower": "Berdayakan pengembangan AI Anda",
|
||||
@ -245,6 +248,7 @@
|
||||
"marketplace.home.trendingReadMoreAbout": "Baca selengkapnya tentang {{title}}",
|
||||
"marketplace.home.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.home.trendingView": "Lihat",
|
||||
"marketplace.loadError": "Gagal memuat. Silakan coba lagi.",
|
||||
"marketplace.moreFrom": "Selengkapnya dari Marketplace",
|
||||
"marketplace.noPluginFound": "Tidak ada integrasi yang ditemukan",
|
||||
"marketplace.partnerTip": "Diverifikasi oleh partner Dify",
|
||||
|
||||
@ -224,6 +224,9 @@
|
||||
"marketplace.allPlugins": "Tutte le integrazioni",
|
||||
"marketplace.and": "e",
|
||||
"marketplace.becomePartner": "Diventa un partner",
|
||||
"marketplace.carousel.goToPage": "Vai alla pagina {{page}}",
|
||||
"marketplace.carousel.scrollNext": "Pagina successiva",
|
||||
"marketplace.carousel.scrollPrevious": "Pagina precedente",
|
||||
"marketplace.difyMarketplace": "Mercato Dify",
|
||||
"marketplace.discover": "Scoprire",
|
||||
"marketplace.empower": "Potenzia lo sviluppo dell'intelligenza artificiale",
|
||||
@ -245,6 +248,7 @@
|
||||
"marketplace.home.trendingReadMoreAbout": "Scopri di più su {{title}}",
|
||||
"marketplace.home.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.home.trendingView": "Visualizza",
|
||||
"marketplace.loadError": "Caricamento non riuscito. Riprova.",
|
||||
"marketplace.moreFrom": "Altro da Marketplace",
|
||||
"marketplace.noPluginFound": "Nessuna integrazione trovata",
|
||||
"marketplace.partnerTip": "Verificato da un partner Dify",
|
||||
|
||||
@ -224,6 +224,9 @@
|
||||
"marketplace.allPlugins": "すべてのインテグレーション",
|
||||
"marketplace.and": "と",
|
||||
"marketplace.becomePartner": "パートナーになる",
|
||||
"marketplace.carousel.goToPage": "{{page}}ページへ移動",
|
||||
"marketplace.carousel.scrollNext": "次のページ",
|
||||
"marketplace.carousel.scrollPrevious": "前のページ",
|
||||
"marketplace.difyMarketplace": "Dify マーケットプレイス",
|
||||
"marketplace.discover": "探索",
|
||||
"marketplace.empower": "AI 開発をサポートする",
|
||||
@ -245,6 +248,7 @@
|
||||
"marketplace.home.trendingReadMoreAbout": "{{title}} の続きを読む",
|
||||
"marketplace.home.trendingTitle": "みんながインストールしているプラグイン",
|
||||
"marketplace.home.trendingView": "表示",
|
||||
"marketplace.loadError": "読み込みに失敗しました。もう一度お試しください。",
|
||||
"marketplace.moreFrom": "マーケットプレイスからのさらなる情報",
|
||||
"marketplace.noPluginFound": "インテグレーションが見つかりません",
|
||||
"marketplace.partnerTip": "このプラグインは Dify のパートナーによって認証されています",
|
||||
|
||||
@ -224,6 +224,9 @@
|
||||
"marketplace.allPlugins": "모든 플러그인",
|
||||
"marketplace.and": "그리고",
|
||||
"marketplace.becomePartner": "파트너 되기",
|
||||
"marketplace.carousel.goToPage": "{{page}}페이지로 이동",
|
||||
"marketplace.carousel.scrollNext": "다음 페이지",
|
||||
"marketplace.carousel.scrollPrevious": "이전 페이지",
|
||||
"marketplace.difyMarketplace": "Dify 마켓플레이스",
|
||||
"marketplace.discover": "발견하다",
|
||||
"marketplace.empower": "AI 개발 역량 강화",
|
||||
@ -245,6 +248,7 @@
|
||||
"marketplace.home.trendingReadMoreAbout": "{{title}}에 대해 더 알아보기",
|
||||
"marketplace.home.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.home.trendingView": "보기",
|
||||
"marketplace.loadError": "불러오지 못했습니다. 다시 시도해 주세요.",
|
||||
"marketplace.moreFrom": "Marketplace 에서 더 보기",
|
||||
"marketplace.noPluginFound": "플러그인을 찾을 수 없습니다.",
|
||||
"marketplace.partnerTip": "Dify 파트너에 의해 확인됨",
|
||||
|
||||
@ -224,6 +224,9 @@
|
||||
"marketplace.allPlugins": "ການເຊື່ອມຕໍ່ທັງໝົດ",
|
||||
"marketplace.and": "ແລະ",
|
||||
"marketplace.becomePartner": "ເຂົ້າຮ່ວມເປັນພັດທະນາມິດ",
|
||||
"marketplace.carousel.goToPage": "ໄປທີ່ໜ້າ {{page}}",
|
||||
"marketplace.carousel.scrollNext": "ໜ້າຕໍ່ໄປ",
|
||||
"marketplace.carousel.scrollPrevious": "ໜ້າກ່ອນໜ້າ",
|
||||
"marketplace.difyMarketplace": "Dify Marketplace",
|
||||
"marketplace.discover": "ຄົ້ນຫາ",
|
||||
"marketplace.empower": "ເສີມພະລັງການພັດທະນາ AI ຂອງທ່ານ",
|
||||
@ -233,6 +236,7 @@
|
||||
"marketplace.home.trendingReadMore": "ອ່ານເພີ່ມເຕີມ",
|
||||
"marketplace.home.trendingReadMoreAbout": "ອ່ານເພີ່ມເຕີມກ່ຽວກັບ {{title}}",
|
||||
"marketplace.home.trendingView": "ເບິ່ງ",
|
||||
"marketplace.loadError": "ໂຫຼດບໍ່ສຳເລັດ. ກະລຸນາລອງໃໝ່ອີກຄັ້ງ.",
|
||||
"marketplace.moreFrom": "ເພີ່ມເຕີມຈາກ Marketplace",
|
||||
"marketplace.noPluginFound": "ບໍ່ພົບການເຊື່ອມຕໍ່",
|
||||
"marketplace.partnerTip": "ໄດ້ຮັບການຢືນຢັນໂດຍພັດທະນາມິດຂອງ Dify",
|
||||
|
||||
@ -224,6 +224,9 @@
|
||||
"marketplace.allPlugins": "Alle plugins",
|
||||
"marketplace.and": "and",
|
||||
"marketplace.becomePartner": "Word partner",
|
||||
"marketplace.carousel.goToPage": "Ga naar pagina {{page}}",
|
||||
"marketplace.carousel.scrollNext": "Volgende pagina",
|
||||
"marketplace.carousel.scrollPrevious": "Vorige pagina",
|
||||
"marketplace.difyMarketplace": "Dify Marketplace",
|
||||
"marketplace.discover": "Discover",
|
||||
"marketplace.empower": "Empower your AI development",
|
||||
@ -245,6 +248,7 @@
|
||||
"marketplace.home.trendingReadMoreAbout": "Lees meer over {{title}}",
|
||||
"marketplace.home.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.home.trendingView": "Bekijken",
|
||||
"marketplace.loadError": "Laden mislukt. Probeer het opnieuw.",
|
||||
"marketplace.moreFrom": "More from Marketplace",
|
||||
"marketplace.noPluginFound": "Geen plugin gevonden",
|
||||
"marketplace.partnerTip": "Verified by a Dify partner",
|
||||
|
||||
@ -224,6 +224,9 @@
|
||||
"marketplace.allPlugins": "Wszystkie integracje",
|
||||
"marketplace.and": "i",
|
||||
"marketplace.becomePartner": "Zostań partnerem",
|
||||
"marketplace.carousel.goToPage": "Przejdź do strony {{page}}",
|
||||
"marketplace.carousel.scrollNext": "Następna strona",
|
||||
"marketplace.carousel.scrollPrevious": "Poprzednia strona",
|
||||
"marketplace.difyMarketplace": "Rynek Dify",
|
||||
"marketplace.discover": "Odkryć",
|
||||
"marketplace.empower": "Zwiększ możliwości rozwoju sztucznej inteligencji",
|
||||
@ -245,6 +248,7 @@
|
||||
"marketplace.home.trendingReadMoreAbout": "Czytaj więcej o {{title}}",
|
||||
"marketplace.home.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.home.trendingView": "Zobacz",
|
||||
"marketplace.loadError": "Nie udało się załadować. Spróbuj ponownie.",
|
||||
"marketplace.moreFrom": "Więcej z Marketplace",
|
||||
"marketplace.noPluginFound": "Nie znaleziono integracji",
|
||||
"marketplace.partnerTip": "Zweryfikowane przez partnera Dify",
|
||||
|
||||
@ -224,6 +224,9 @@
|
||||
"marketplace.allPlugins": "Todas as integrações",
|
||||
"marketplace.and": "e",
|
||||
"marketplace.becomePartner": "Torne-se um parceiro",
|
||||
"marketplace.carousel.goToPage": "Ir para a página {{page}}",
|
||||
"marketplace.carousel.scrollNext": "Próxima página",
|
||||
"marketplace.carousel.scrollPrevious": "Página anterior",
|
||||
"marketplace.difyMarketplace": "Mercado Dify",
|
||||
"marketplace.discover": "Descobrir",
|
||||
"marketplace.empower": "Capacite seu desenvolvimento de IA",
|
||||
@ -245,6 +248,7 @@
|
||||
"marketplace.home.trendingReadMoreAbout": "Leia mais sobre {{title}}",
|
||||
"marketplace.home.trendingTitle": "Os plugins que todos estão instalando",
|
||||
"marketplace.home.trendingView": "Ver",
|
||||
"marketplace.loadError": "Falha ao carregar. Tente novamente.",
|
||||
"marketplace.moreFrom": "Mais do Marketplace",
|
||||
"marketplace.noPluginFound": "Nenhuma integração encontrada",
|
||||
"marketplace.partnerTip": "Verificado por um parceiro da Dify",
|
||||
|
||||
@ -224,6 +224,9 @@
|
||||
"marketplace.allPlugins": "Toate pluginurile",
|
||||
"marketplace.and": "și",
|
||||
"marketplace.becomePartner": "Deveniți partener",
|
||||
"marketplace.carousel.goToPage": "Mergi la pagina {{page}}",
|
||||
"marketplace.carousel.scrollNext": "Pagina următoare",
|
||||
"marketplace.carousel.scrollPrevious": "Pagina anterioară",
|
||||
"marketplace.difyMarketplace": "Piața Dify",
|
||||
"marketplace.discover": "Descoperi",
|
||||
"marketplace.empower": "Îmbunătățește-ți dezvoltarea AI",
|
||||
@ -245,6 +248,7 @@
|
||||
"marketplace.home.trendingReadMoreAbout": "Citește mai mult despre {{title}}",
|
||||
"marketplace.home.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.home.trendingView": "Vezi",
|
||||
"marketplace.loadError": "Încărcarea a eșuat. Încercați din nou.",
|
||||
"marketplace.moreFrom": "Mai multe din Marketplace",
|
||||
"marketplace.noPluginFound": "Nu s-a găsit niciun plugin",
|
||||
"marketplace.partnerTip": "Verificat de un partener Dify",
|
||||
|
||||
@ -224,6 +224,9 @@
|
||||
"marketplace.allPlugins": "Все плагины",
|
||||
"marketplace.and": "и",
|
||||
"marketplace.becomePartner": "Стать партнёром",
|
||||
"marketplace.carousel.goToPage": "Перейти на страницу {{page}}",
|
||||
"marketplace.carousel.scrollNext": "Следующая страница",
|
||||
"marketplace.carousel.scrollPrevious": "Предыдущая страница",
|
||||
"marketplace.difyMarketplace": "Торговая площадка Dify",
|
||||
"marketplace.discover": "Обнаруживать",
|
||||
"marketplace.empower": "Расширьте возможности разработки ИИ",
|
||||
@ -245,6 +248,7 @@
|
||||
"marketplace.home.trendingReadMoreAbout": "Подробнее о {{title}}",
|
||||
"marketplace.home.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.home.trendingView": "Открыть",
|
||||
"marketplace.loadError": "Не удалось загрузить. Повторите попытку.",
|
||||
"marketplace.moreFrom": "Больше из Marketplace",
|
||||
"marketplace.noPluginFound": "Плагин не найден",
|
||||
"marketplace.partnerTip": "Подтверждено партнером Dify",
|
||||
|
||||
@ -224,6 +224,9 @@
|
||||
"marketplace.allPlugins": "Vsi vtičniki",
|
||||
"marketplace.and": "in",
|
||||
"marketplace.becomePartner": "Postanite partner",
|
||||
"marketplace.carousel.goToPage": "Pojdi na stran {{page}}",
|
||||
"marketplace.carousel.scrollNext": "Naslednja stran",
|
||||
"marketplace.carousel.scrollPrevious": "Prejšnja stran",
|
||||
"marketplace.difyMarketplace": "Dify Marketplace",
|
||||
"marketplace.discover": "Odkrijte",
|
||||
"marketplace.empower": "Okrepite svoj razvoj AI",
|
||||
@ -245,6 +248,7 @@
|
||||
"marketplace.home.trendingReadMoreAbout": "Preberi več o {{title}}",
|
||||
"marketplace.home.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.home.trendingView": "Ogled",
|
||||
"marketplace.loadError": "Nalaganje ni uspelo. Poskusite znova.",
|
||||
"marketplace.moreFrom": "Več iz tržnice",
|
||||
"marketplace.noPluginFound": "Nobenega vtičnika ni bilo najti.",
|
||||
"marketplace.partnerTip": "Potrjeno s strani partnerja Dify",
|
||||
|
||||
@ -224,6 +224,9 @@
|
||||
"marketplace.allPlugins": "ปลั๊กอินทั้งหมด",
|
||||
"marketplace.and": "และ",
|
||||
"marketplace.becomePartner": "เป็นพันธมิตร",
|
||||
"marketplace.carousel.goToPage": "ไปที่หน้า {{page}}",
|
||||
"marketplace.carousel.scrollNext": "หน้าถัดไป",
|
||||
"marketplace.carousel.scrollPrevious": "หน้าก่อนหน้า",
|
||||
"marketplace.difyMarketplace": "ตลาด Dify",
|
||||
"marketplace.discover": "ค้นพบ",
|
||||
"marketplace.empower": "เพิ่มศักยภาพในการพัฒนา AI ของคุณ",
|
||||
@ -245,6 +248,7 @@
|
||||
"marketplace.home.trendingReadMoreAbout": "อ่านเพิ่มเติมเกี่ยวกับ {{title}}",
|
||||
"marketplace.home.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.home.trendingView": "ดู",
|
||||
"marketplace.loadError": "โหลดไม่สำเร็จ โปรดลองอีกครั้ง",
|
||||
"marketplace.moreFrom": "แอปเพิ่มเติมจาก Marketplace",
|
||||
"marketplace.noPluginFound": "ไม่พบปลั๊กอิน",
|
||||
"marketplace.partnerTip": "ได้รับการตรวจสอบโดยพันธมิตรของ Dify",
|
||||
|
||||
@ -224,6 +224,9 @@
|
||||
"marketplace.allPlugins": "Tüm eklentiler",
|
||||
"marketplace.and": "ve",
|
||||
"marketplace.becomePartner": "Partner Olun",
|
||||
"marketplace.carousel.goToPage": "{{page}}. sayfaya git",
|
||||
"marketplace.carousel.scrollNext": "Sonraki sayfa",
|
||||
"marketplace.carousel.scrollPrevious": "Önceki sayfa",
|
||||
"marketplace.difyMarketplace": "Dify Pazar Yeri",
|
||||
"marketplace.discover": "Keşfet",
|
||||
"marketplace.empower": "Yapay zeka geliştirmenizi güçlendirin",
|
||||
@ -245,6 +248,7 @@
|
||||
"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.loadError": "Yüklenemedi. Lütfen tekrar deneyin.",
|
||||
"marketplace.moreFrom": "Pazar Yeri'nden daha fazlası",
|
||||
"marketplace.noPluginFound": "Eklenti bulunamadı",
|
||||
"marketplace.partnerTip": "Dify partner'ı tarafından doğrulandı",
|
||||
|
||||
@ -224,6 +224,9 @@
|
||||
"marketplace.allPlugins": "Всі плагіни",
|
||||
"marketplace.and": "і",
|
||||
"marketplace.becomePartner": "Стати партнером",
|
||||
"marketplace.carousel.goToPage": "Перейти на сторінку {{page}}",
|
||||
"marketplace.carousel.scrollNext": "Наступна сторінка",
|
||||
"marketplace.carousel.scrollPrevious": "Попередня сторінка",
|
||||
"marketplace.difyMarketplace": "Dify Marketplace",
|
||||
"marketplace.discover": "Виявити",
|
||||
"marketplace.empower": "Розширюйте можливості розробки штучного інтелекту",
|
||||
@ -245,6 +248,7 @@
|
||||
"marketplace.home.trendingReadMoreAbout": "Дізнатися більше про {{title}}",
|
||||
"marketplace.home.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.home.trendingView": "Переглянути",
|
||||
"marketplace.loadError": "Не вдалося завантажити. Спробуйте ще раз.",
|
||||
"marketplace.moreFrom": "Більше від Marketplace",
|
||||
"marketplace.noPluginFound": "Плагін не знайдено",
|
||||
"marketplace.partnerTip": "Перевірено партнером Dify",
|
||||
|
||||
@ -224,6 +224,9 @@
|
||||
"marketplace.allPlugins": "Tất cả plugin",
|
||||
"marketplace.and": "và",
|
||||
"marketplace.becomePartner": "Trở thành đối tác",
|
||||
"marketplace.carousel.goToPage": "Đi tới trang {{page}}",
|
||||
"marketplace.carousel.scrollNext": "Trang sau",
|
||||
"marketplace.carousel.scrollPrevious": "Trang trước",
|
||||
"marketplace.difyMarketplace": "Thị trường Dify",
|
||||
"marketplace.discover": "Khám phá",
|
||||
"marketplace.empower": "Hỗ trợ phát triển AI của bạn",
|
||||
@ -245,6 +248,7 @@
|
||||
"marketplace.home.trendingReadMoreAbout": "Đọc thêm về {{title}}",
|
||||
"marketplace.home.trendingTitle": "The plugins everyone is installing",
|
||||
"marketplace.home.trendingView": "Xem",
|
||||
"marketplace.loadError": "Tải không thành công. Vui lòng thử lại.",
|
||||
"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",
|
||||
|
||||
@ -224,6 +224,9 @@
|
||||
"marketplace.allPlugins": "所有集成",
|
||||
"marketplace.and": "和",
|
||||
"marketplace.becomePartner": "成为合作伙伴",
|
||||
"marketplace.carousel.goToPage": "转到第 {{page}} 页",
|
||||
"marketplace.carousel.scrollNext": "下一页",
|
||||
"marketplace.carousel.scrollPrevious": "上一页",
|
||||
"marketplace.difyMarketplace": "Dify Marketplace",
|
||||
"marketplace.discover": "探索",
|
||||
"marketplace.empower": "助力您的 AI 开发",
|
||||
@ -245,6 +248,7 @@
|
||||
"marketplace.home.trendingReadMoreAbout": "阅读更多关于 {{title}} 的内容",
|
||||
"marketplace.home.trendingTitle": "大家都在安装的插件",
|
||||
"marketplace.home.trendingView": "查看",
|
||||
"marketplace.loadError": "加载失败,请重试。",
|
||||
"marketplace.moreFrom": "来自 Marketplace 的更多内容",
|
||||
"marketplace.noPluginFound": "未找到集成",
|
||||
"marketplace.partnerTip": "此插件由 Dify 合作伙伴认证",
|
||||
|
||||
@ -224,6 +224,9 @@
|
||||
"marketplace.allPlugins": "所有集成",
|
||||
"marketplace.and": "和",
|
||||
"marketplace.becomePartner": "成為合作夥伴",
|
||||
"marketplace.carousel.goToPage": "轉到第 {{page}} 頁",
|
||||
"marketplace.carousel.scrollNext": "下一頁",
|
||||
"marketplace.carousel.scrollPrevious": "上一頁",
|
||||
"marketplace.difyMarketplace": "Dify Marketplace",
|
||||
"marketplace.discover": "發現",
|
||||
"marketplace.empower": "為您的 AI 開發提供支援",
|
||||
@ -245,6 +248,7 @@
|
||||
"marketplace.home.trendingReadMoreAbout": "閱讀更多關於 {{title}} 的內容",
|
||||
"marketplace.home.trendingTitle": "大家都在安裝的外掛程式",
|
||||
"marketplace.home.trendingView": "查看",
|
||||
"marketplace.loadError": "載入失敗,請重試。",
|
||||
"marketplace.moreFrom": "來自 Marketplace 的更多內容",
|
||||
"marketplace.noPluginFound": "未找到集成",
|
||||
"marketplace.partnerTip": "由 Dify 合作夥伴驗證",
|
||||
|
||||
@ -83,7 +83,7 @@ describe('marketplace template discovery', () => {
|
||||
mocks.templateCollections.mockRejectedValueOnce(new Error('Unavailable'))
|
||||
|
||||
const failed = await getMarketplaceTemplateCollectionsAndTemplates()
|
||||
expect(failed).toEqual({ collections: [], templatesByCollection: {} })
|
||||
expect(failed).toEqual({ collections: [], templatesByCollection: {}, ok: false })
|
||||
|
||||
mocks.templateCollections.mockResolvedValue({
|
||||
data: {
|
||||
@ -95,6 +95,7 @@ describe('marketplace template discovery', () => {
|
||||
})
|
||||
|
||||
const recovered = await getMarketplaceTemplateCollectionsAndTemplates()
|
||||
expect(recovered.ok).toBe(true)
|
||||
expect(recovered.templatesByCollection).toEqual({ featured: [{ id: 'template-1' }] })
|
||||
})
|
||||
|
||||
@ -123,6 +124,18 @@ describe('marketplace template discovery', () => {
|
||||
categories: ['marketing'],
|
||||
},
|
||||
})
|
||||
expect(result).toEqual({ page: 2, templates: [{ id: 'template-1' }], total: 1 })
|
||||
expect(result).toEqual({ ok: true, page: 2, templates: [{ id: 'template-1' }], total: 1 })
|
||||
})
|
||||
|
||||
it('marks a failed template search instead of reporting an empty result', async () => {
|
||||
const { searchMarketplaceTemplates } = await importDiscovery()
|
||||
mocks.templateSearch.mockRejectedValueOnce(new Error('Unavailable'))
|
||||
|
||||
const result = await searchMarketplaceTemplates({
|
||||
category: 'all',
|
||||
query: 'campaign',
|
||||
})
|
||||
|
||||
expect(result).toEqual({ ok: false, page: 1, templates: [], total: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
@ -7,6 +7,11 @@ import { marketplaceClient } from './client'
|
||||
export type MarketplaceTemplateCollectionsResult = {
|
||||
collections: MarketplaceTemplateCollection[]
|
||||
templatesByCollection: Record<string, MarketplaceTemplate[]>
|
||||
/**
|
||||
* False when the Marketplace API request failed, so the UI can render an
|
||||
* error state instead of claiming the catalog is empty.
|
||||
*/
|
||||
ok: boolean
|
||||
}
|
||||
|
||||
export const TEMPLATE_SEARCH_PAGE_SIZE = 40
|
||||
@ -19,9 +24,10 @@ type SearchMarketplaceTemplatesOptions = {
|
||||
sortOrder?: string
|
||||
}
|
||||
|
||||
const EMPTY_COLLECTIONS_RESULT: MarketplaceTemplateCollectionsResult = {
|
||||
const FAILED_COLLECTIONS_RESULT: MarketplaceTemplateCollectionsResult = {
|
||||
collections: [],
|
||||
templatesByCollection: {},
|
||||
ok: false,
|
||||
}
|
||||
|
||||
const COLLECTION_PREVIEW_TEMPLATE_LIMIT = 24
|
||||
@ -73,6 +79,7 @@ async function fetchCollectionsAndTemplates(): Promise<MarketplaceTemplateCollec
|
||||
return {
|
||||
collections,
|
||||
templatesByCollection: Object.fromEntries(entries),
|
||||
ok: true,
|
||||
}
|
||||
}
|
||||
|
||||
@ -92,7 +99,7 @@ export async function getMarketplaceTemplateCollectionsAndTemplates(): Promise<M
|
||||
collectionsCache = { expiresAt: Date.now() + COLLECTIONS_CACHE_TTL_MS, result }
|
||||
return result
|
||||
})
|
||||
.catch(() => EMPTY_COLLECTIONS_RESULT)
|
||||
.catch(() => FAILED_COLLECTIONS_RESULT)
|
||||
.finally(() => {
|
||||
collectionsInFlight = null
|
||||
})
|
||||
@ -120,12 +127,16 @@ export async function searchMarketplaceTemplates({
|
||||
})
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
page,
|
||||
templates: response.data?.templates ?? [],
|
||||
total: response.data?.total ?? 0,
|
||||
}
|
||||
} catch {
|
||||
// Marked as failed so callers can distinguish an API outage from a
|
||||
// genuinely empty search result.
|
||||
return {
|
||||
ok: false,
|
||||
page,
|
||||
templates: [],
|
||||
total: 0,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user