mirror of
https://github.com/langgenius/dify.git
synced 2026-09-05 08:48:10 +08:00
feat(web): add embedded marketplace templates
This commit is contained in:
parent
00861ac5eb
commit
1bf2d191fe
@ -53,9 +53,24 @@ export type MarketplaceTemplate = {
|
||||
icon: string
|
||||
icon_background: string
|
||||
icon_file_key: string
|
||||
publisher_unique_handle: string
|
||||
publisher_unique_handle?: string
|
||||
publisher_handle?: string
|
||||
publisher_type?: string
|
||||
creator_email?: string
|
||||
usage_count: number
|
||||
categories: string[]
|
||||
deps_plugins?: string[]
|
||||
preferred_languages?: string[]
|
||||
badges?: string[]
|
||||
}
|
||||
|
||||
export type MarketplaceTemplateCollection = {
|
||||
name: string
|
||||
description: Record<string, string>
|
||||
label: Record<string, string>
|
||||
searchable?: boolean
|
||||
search_params?: SearchParamsFromCollection
|
||||
priority: number
|
||||
}
|
||||
|
||||
export type MarketplacePluginCategory =
|
||||
@ -154,6 +169,27 @@ export type TemplateDetailResponse = {
|
||||
data: MarketplaceTemplate
|
||||
}
|
||||
|
||||
export type TemplateCollectionsResponse = {
|
||||
data?: {
|
||||
collections?: MarketplaceTemplateCollection[]
|
||||
total?: number
|
||||
}
|
||||
}
|
||||
|
||||
export type TemplateCollectionTemplatesResponse = {
|
||||
data?: {
|
||||
templates?: MarketplaceTemplate[]
|
||||
total?: number
|
||||
}
|
||||
}
|
||||
|
||||
export type TemplateSearchResponse = {
|
||||
data?: {
|
||||
templates?: MarketplaceTemplate[]
|
||||
total?: number
|
||||
}
|
||||
}
|
||||
|
||||
export type DownloadPluginResponse = Blob
|
||||
|
||||
const bannerListContract = base
|
||||
@ -227,6 +263,57 @@ const templateDetailContract = base
|
||||
)
|
||||
.output(type<TemplateDetailResponse>())
|
||||
|
||||
const templateCollectionsContract = base
|
||||
.route({
|
||||
path: '/template-collections',
|
||||
method: 'GET',
|
||||
})
|
||||
.input(
|
||||
type<{
|
||||
query?: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
}>(),
|
||||
)
|
||||
.output(type<TemplateCollectionsResponse>())
|
||||
|
||||
const templateCollectionTemplatesContract = base
|
||||
.route({
|
||||
path: '/template-collections/{collectionName}/templates',
|
||||
method: 'POST',
|
||||
})
|
||||
.input(
|
||||
type<{
|
||||
params: {
|
||||
collectionName: string
|
||||
}
|
||||
body?: {
|
||||
limit?: number
|
||||
}
|
||||
}>(),
|
||||
)
|
||||
.output(type<TemplateCollectionTemplatesResponse>())
|
||||
|
||||
const templateSearchContract = base
|
||||
.route({
|
||||
path: '/templates/search/advanced',
|
||||
method: 'POST',
|
||||
})
|
||||
.input(
|
||||
type<{
|
||||
body: {
|
||||
page: number
|
||||
page_size: number
|
||||
query: string
|
||||
sort_by: string
|
||||
sort_order: string
|
||||
categories?: string[]
|
||||
}
|
||||
}>(),
|
||||
)
|
||||
.output(type<TemplateSearchResponse>())
|
||||
|
||||
const downloadPluginContract = base
|
||||
.route({
|
||||
path: '/plugins/{organization}/{pluginName}/{version}/download',
|
||||
@ -250,7 +337,10 @@ export const marketplaceRouterContract = {
|
||||
collections: collectionsContract,
|
||||
collectionPlugins: collectionPluginsContract,
|
||||
searchAdvanced: searchAdvancedContract,
|
||||
templateCollections: templateCollectionsContract,
|
||||
templateCollectionTemplates: templateCollectionTemplatesContract,
|
||||
templateDetail: templateDetailContract,
|
||||
templateSearch: templateSearchContract,
|
||||
downloadPlugin: downloadPluginContract,
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,59 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { redirect } from '@/next/navigation'
|
||||
import TemplatesPage from '../page'
|
||||
|
||||
vi.mock('@/app/components/plugins/marketplace/templates', () => ({
|
||||
EmbeddedTemplatesMarketplace: ({ category, query }: { category: string; query: string }) => (
|
||||
<div>{`Templates catalog: ${category}:${query}`}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n-config/server', () => ({
|
||||
getLocaleOnServer: () => Promise.resolve('en-US'),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
redirect: vi.fn((path: string) => {
|
||||
throw new Error(`redirect:${path}`)
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('embedded templates route', () => {
|
||||
it('renders the templates catalog at /templates', async () => {
|
||||
const page = await TemplatesPage({
|
||||
params: Promise.resolve({}),
|
||||
searchParams: Promise.resolve({ q: 'agent' }),
|
||||
})
|
||||
|
||||
render(page)
|
||||
|
||||
expect(screen.getByText('Templates catalog: all:agent')).toBeInTheDocument()
|
||||
expect(screen.getByText('Templates catalog: all:agent').parentElement).toHaveAttribute(
|
||||
'id',
|
||||
'marketplace-container',
|
||||
)
|
||||
})
|
||||
|
||||
it('passes a supported path category to the templates catalog', async () => {
|
||||
const page = await TemplatesPage({
|
||||
params: Promise.resolve({ category: ['marketing'] }),
|
||||
searchParams: Promise.resolve({}),
|
||||
})
|
||||
|
||||
render(page)
|
||||
|
||||
expect(screen.getByText('Templates catalog: marketing:')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens template recommendations in the existing Dify import flow', async () => {
|
||||
await expect(
|
||||
TemplatesPage({
|
||||
params: Promise.resolve({}),
|
||||
searchParams: Promise.resolve({ tid: 'template/one' }),
|
||||
}),
|
||||
).rejects.toThrow('redirect:/apps?template-id=template%2Fone')
|
||||
|
||||
expect(redirect).toHaveBeenCalledWith('/apps?template-id=template%2Fone')
|
||||
})
|
||||
})
|
||||
46
web/app/(commonLayout)/templates/[[...category]]/page.tsx
Normal file
46
web/app/(commonLayout)/templates/[[...category]]/page.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import { EmbeddedTemplatesMarketplace } from '@/app/components/plugins/marketplace/templates'
|
||||
import { isTemplateCategory } from '@/app/components/plugins/marketplace/templates/categories'
|
||||
import { getLocaleOnServer } from '@/i18n-config/server'
|
||||
import { redirect } from '@/next/navigation'
|
||||
|
||||
type TemplatesPageProps = {
|
||||
params: Promise<{ category?: string[] }>
|
||||
searchParams: Promise<{
|
||||
q?: string
|
||||
sort_by?: string
|
||||
sort_order?: string
|
||||
tid?: string
|
||||
view?: string
|
||||
}>
|
||||
}
|
||||
|
||||
export default async function TemplatesPage({ params, searchParams }: TemplatesPageProps) {
|
||||
const [resolvedParams, resolvedSearchParams, locale] = await Promise.all([
|
||||
params,
|
||||
searchParams,
|
||||
getLocaleOnServer(),
|
||||
])
|
||||
|
||||
if (resolvedSearchParams.tid) {
|
||||
redirect(`/apps?template-id=${encodeURIComponent(resolvedSearchParams.tid)}`)
|
||||
}
|
||||
|
||||
const requestedCategory = resolvedParams.category?.[0]
|
||||
const category = isTemplateCategory(requestedCategory) ? requestedCategory : 'all'
|
||||
|
||||
return (
|
||||
<div
|
||||
id="marketplace-container"
|
||||
className="flex h-full min-h-0 flex-col overflow-y-auto bg-background-default"
|
||||
>
|
||||
<EmbeddedTemplatesMarketplace
|
||||
category={category}
|
||||
locale={locale}
|
||||
query={resolvedSearchParams.q ?? ''}
|
||||
sortBy={resolvedSearchParams.sort_by}
|
||||
sortOrder={resolvedSearchParams.sort_order}
|
||||
view={resolvedSearchParams.view}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -906,14 +906,17 @@ describe('MainNav', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('marks marketplace active on marketplace routes', () => {
|
||||
mockPathname = '/marketplace'
|
||||
it.each(['/marketplace', '/plugins', '/templates', '/templates/marketing'])(
|
||||
'marks marketplace active on route %s',
|
||||
(pathname) => {
|
||||
mockPathname = pathname
|
||||
|
||||
renderMainNav()
|
||||
renderMainNav()
|
||||
|
||||
const marketplaceLink = screen.getByRole('link', { name: /common.mainNav.marketplace/ })
|
||||
expect(marketplaceLink).toHaveClass(activeGradientMaskClassName)
|
||||
})
|
||||
const marketplaceLink = screen.getByRole('link', { name: /common.mainNav.marketplace/ })
|
||||
expect(marketplaceLink).toHaveClass(activeGradientMaskClassName)
|
||||
},
|
||||
)
|
||||
|
||||
it('marks roster active on roster routes', () => {
|
||||
mockPathname = '/agents'
|
||||
|
||||
@ -90,7 +90,9 @@ export const MAIN_NAV_ROUTES = [
|
||||
href: '/marketplace',
|
||||
labelKey: 'mainNav.marketplace',
|
||||
active: (path: string) =>
|
||||
isPathUnderRoute(path, '/marketplace') || isPathUnderRoute(path, '/plugins'),
|
||||
isPathUnderRoute(path, '/marketplace') ||
|
||||
isPathUnderRoute(path, '/plugins') ||
|
||||
isPathUnderRoute(path, '/templates'),
|
||||
icon: 'i-custom-vender-main-nav-marketplace',
|
||||
activeIcon: 'i-custom-vender-main-nav-marketplace-active',
|
||||
visibility: VISIBLE_TO_ALL,
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import PartnerDark from '@/app/components/base/icons/src/public/plugins/PartnerDark'
|
||||
import PartnerLight from '@/app/components/base/icons/src/public/plugins/PartnerLight'
|
||||
|
||||
@ -130,7 +130,7 @@ describe('HomeCatalogNavigation', () => {
|
||||
expect(screen.queryByTestId('plugin-type-switch')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('links Dify users to the hosted Marketplace templates page', () => {
|
||||
it('keeps Dify template navigation on the current origin', () => {
|
||||
renderNavigation(false)
|
||||
|
||||
expect(screen.getByRole('link', { name: 'plugin.marketplace.home.plugins' })).toHaveAttribute(
|
||||
@ -139,7 +139,7 @@ describe('HomeCatalogNavigation', () => {
|
||||
)
|
||||
expect(
|
||||
screen.getByRole('link', { name: /plugin\.marketplace\.home\.templates/ }),
|
||||
).toHaveAttribute('href', 'https://marketplace.dify.ai/templates?source=console')
|
||||
).toHaveAttribute('href', '/templates')
|
||||
})
|
||||
|
||||
it('shows the compact navigation and header tabs after reaching the sticky header', () => {
|
||||
|
||||
@ -23,15 +23,15 @@ const HomeCatalogTabs = ({
|
||||
}: HomeCatalogTabsProps) => {
|
||||
const { t } = useTranslation()
|
||||
const catalogParams = language ? { language } : undefined
|
||||
const getCatalogHref = (path: string) => {
|
||||
if (!isMarketplacePlatform) return getMarketplaceUrl(path, catalogParams)
|
||||
|
||||
const getRelativeCatalogHref = (path: string) => {
|
||||
const searchParams = new URLSearchParams(catalogParams)
|
||||
const queryString = searchParams.toString()
|
||||
return queryString ? `${path}?${queryString}` : path
|
||||
}
|
||||
const pluginsHref = getCatalogHref('/plugins')
|
||||
const templatesHref = getCatalogHref('/templates')
|
||||
const pluginsHref = isMarketplacePlatform
|
||||
? getRelativeCatalogHref('/plugins')
|
||||
: getMarketplaceUrl('/plugins', catalogParams)
|
||||
const templatesHref = getRelativeCatalogHref('/templates')
|
||||
const isPluginsActive = activeTab === 'plugins'
|
||||
const isTemplatesActive = activeTab === 'templates'
|
||||
const pluginsLabel = labels?.plugins ?? t(($) => $['marketplace.home.plugins'], { ns: 'plugin' })
|
||||
|
||||
@ -5,7 +5,10 @@ import Carousel from '../carousel'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const listeners = new Map<string, Set<() => void>>()
|
||||
const carouselState = { selectedIndex: 0 }
|
||||
const carouselState = {
|
||||
scrollSnaps: [0, 1, 2, 3, 4],
|
||||
selectedIndex: 0,
|
||||
}
|
||||
const api = {
|
||||
off: vi.fn((event: string, listener: () => void) => {
|
||||
listeners.get(event)?.delete(listener)
|
||||
@ -17,7 +20,7 @@ const mocks = vi.hoisted(() => {
|
||||
}),
|
||||
scrollNext: vi.fn(),
|
||||
scrollPrev: vi.fn(),
|
||||
scrollSnapList: vi.fn(() => [0, 1, 2, 3, 4]),
|
||||
scrollSnapList: vi.fn(() => carouselState.scrollSnaps),
|
||||
scrollTo: vi.fn(),
|
||||
selectedScrollSnap: vi.fn(() => carouselState.selectedIndex),
|
||||
}
|
||||
@ -108,6 +111,7 @@ describe('Marketplace Carousel', () => {
|
||||
mocks.listeners.clear()
|
||||
mocks.autoplayInstances.length = 0
|
||||
mocks.autoplayOptions.length = 0
|
||||
mocks.carouselState.scrollSnaps = [0, 1, 2, 3, 4]
|
||||
mocks.carouselState.selectedIndex = 0
|
||||
intersectionObservers.length = 0
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
@ -263,4 +267,16 @@ describe('Marketplace Carousel', () => {
|
||||
})
|
||||
expect(intersectionObservers).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not start managed autoplay when the carousel has only one page', () => {
|
||||
installIntersectionObserver()
|
||||
mocks.carouselState.scrollSnaps = [0]
|
||||
|
||||
render(<Carousel pages={pages.slice(0, 1)} autoPlay deferMountPages pauseWhenOffscreen />)
|
||||
const autoplay = mocks.autoplayInstances[0]!
|
||||
|
||||
triggerIntersection(intersectionObservers[0]!, 1)
|
||||
|
||||
expect(autoplay.play).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -226,7 +226,10 @@ const Carousel = ({
|
||||
let isReducedMotion = reducedMotionQuery?.matches ?? false
|
||||
|
||||
const syncAutoplay = () => {
|
||||
if (isInViewport && isDocumentVisible && !isReducedMotion && !isHovered) autoplay.play()
|
||||
const hasMultiplePages = api.scrollSnapList().length > 1
|
||||
|
||||
if (hasMultiplePages && isInViewport && isDocumentVisible && !isReducedMotion && !isHovered)
|
||||
autoplay.play()
|
||||
else autoplay.stop()
|
||||
}
|
||||
const handleVisibilityChange = () => {
|
||||
|
||||
@ -0,0 +1,35 @@
|
||||
import type { MarketplaceTemplate } from '@dify/contracts/marketplace'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import TemplateCard from '../template-card'
|
||||
|
||||
vi.mock('@/app/components/base/app-icon', () => ({
|
||||
default: () => <div aria-hidden />,
|
||||
}))
|
||||
|
||||
const template: MarketplaceTemplate = {
|
||||
id: 'template/one',
|
||||
template_name: 'Campaign planner',
|
||||
overview: 'Plan a launch campaign.',
|
||||
icon: '📄',
|
||||
icon_background: '#fff',
|
||||
icon_file_key: '',
|
||||
publisher_unique_handle: 'dify',
|
||||
usage_count: 1200,
|
||||
categories: ['marketing'],
|
||||
badges: ['partner'],
|
||||
}
|
||||
|
||||
describe('TemplateCard', () => {
|
||||
it('opens a Marketplace template through the Dify import flow', () => {
|
||||
render(<TemplateCard partnerText="Verified by a Dify partner" template={template} />)
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Campaign planner' })).toHaveAttribute(
|
||||
'href',
|
||||
'/apps?template-id=template%2Fone',
|
||||
)
|
||||
expect(screen.getByText('dify')).toBeInTheDocument()
|
||||
expect(screen.getByText('1.2k')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Verified by a Dify partner')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,17 @@
|
||||
export const TEMPLATE_CATEGORIES = [
|
||||
'all',
|
||||
'marketing',
|
||||
'sales',
|
||||
'support',
|
||||
'operations',
|
||||
'it',
|
||||
'knowledge',
|
||||
'design',
|
||||
'others',
|
||||
] as const
|
||||
|
||||
export type TemplateCategory = (typeof TEMPLATE_CATEGORIES)[number]
|
||||
|
||||
export function isTemplateCategory(value: string | undefined): value is TemplateCategory {
|
||||
return TEMPLATE_CATEGORIES.includes(value as TemplateCategory)
|
||||
}
|
||||
274
web/app/components/plugins/marketplace/templates/index.tsx
Normal file
274
web/app/components/plugins/marketplace/templates/index.tsx
Normal file
@ -0,0 +1,274 @@
|
||||
import type { MarketplaceTemplate } from '@dify/contracts/marketplace'
|
||||
import type { TemplateCategory } from './categories'
|
||||
import type { Locale } from '@/i18n-config'
|
||||
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 {
|
||||
getMarketplaceTemplateCollectionsAndTemplates,
|
||||
searchMarketplaceTemplates,
|
||||
} from '@/service/marketplace-template-discovery'
|
||||
import { fetchPluginBanners } from '../home/banners'
|
||||
import HomeCatalogNavigation from '../home/home-catalog-navigation'
|
||||
import HomeCatalogTabs from '../home/home-catalog-tabs'
|
||||
import HomeHeader from '../home/home-header'
|
||||
import HomeHero from '../home/home-hero'
|
||||
import HomeSearch from '../home/home-search'
|
||||
import { HomeStickyStateProvider } from '../home/home-sticky-state-provider'
|
||||
import styles from '../home/home-sticky.module.css'
|
||||
import HomeTrending from '../home/home-trending'
|
||||
import { GRID_CLASS } from '../list/collection-constants'
|
||||
import pluginTypeStyles from '../plugin-type-switch.module.css'
|
||||
import { TEMPLATE_CATEGORIES } from './categories'
|
||||
import TemplateCard from './template-card'
|
||||
import TemplateCollectionList from './template-collection-list'
|
||||
import { filterTemplatesForLocale } from './template-language'
|
||||
|
||||
type EmbeddedTemplatesMarketplaceProps = {
|
||||
category: TemplateCategory
|
||||
locale: Locale
|
||||
query: string
|
||||
sortBy?: string
|
||||
sortOrder?: string
|
||||
view?: string
|
||||
}
|
||||
|
||||
type TemplateCategoryLabels = Record<TemplateCategory, string>
|
||||
|
||||
function TemplateSearchForm({
|
||||
action,
|
||||
placeholder,
|
||||
query,
|
||||
}: {
|
||||
action: string
|
||||
placeholder: string
|
||||
query: string
|
||||
}) {
|
||||
return (
|
||||
<form action={action} className="relative w-full shrink-0">
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute top-1/2 left-3 i-ri-search-line size-4 -translate-y-1/2 text-text-tertiary"
|
||||
/>
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
defaultValue={query}
|
||||
placeholder={placeholder}
|
||||
className="h-9 w-full rounded-[10px] border-[0.5px] border-components-input-border-active bg-components-input-bg-normal py-2 pr-3 pl-9 text-sm text-text-primary outline-none placeholder:text-text-quaternary focus:border-state-accent-solid"
|
||||
/>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function TemplateCategoryNavigation({
|
||||
activeCategory,
|
||||
ariaLabel,
|
||||
labels,
|
||||
query,
|
||||
}: {
|
||||
activeCategory: TemplateCategory
|
||||
ariaLabel: string
|
||||
labels: TemplateCategoryLabels
|
||||
query: string
|
||||
}) {
|
||||
return (
|
||||
<nav
|
||||
aria-label={ariaLabel}
|
||||
className="flex w-full shrink-0 scrollbar-none items-center justify-start gap-1 overflow-x-auto"
|
||||
>
|
||||
{TEMPLATE_CATEGORIES.map((category) => {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (query) searchParams.set('q', query)
|
||||
const queryString = searchParams.toString()
|
||||
const href = `/templates/${category}${queryString ? `?${queryString}` : ''}`
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={category}
|
||||
href={href}
|
||||
scroll={false}
|
||||
aria-current={category === activeCategory ? 'page' : undefined}
|
||||
className={cn(
|
||||
'flex h-8 min-w-12 shrink-0 cursor-pointer items-center justify-center rounded-lg border border-transparent px-2.5 system-md-medium whitespace-nowrap text-text-tertiary outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid',
|
||||
pluginTypeStyles.homeItem,
|
||||
category === activeCategory && pluginTypeStyles.homeItemActive,
|
||||
)}
|
||||
>
|
||||
{labels[category]}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-60 items-center justify-center rounded-xl border border-dashed border-divider-regular text-sm text-text-tertiary">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TemplateGrid({
|
||||
partnerText,
|
||||
templates,
|
||||
}: {
|
||||
partnerText: string
|
||||
templates: MarketplaceTemplate[]
|
||||
}) {
|
||||
return (
|
||||
<div className={GRID_CLASS}>
|
||||
{templates.map((template) => (
|
||||
<TemplateCard key={template.id} partnerText={partnerText} template={template} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export async function EmbeddedTemplatesMarketplace({
|
||||
category,
|
||||
locale,
|
||||
query,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
view,
|
||||
}: EmbeddedTemplatesMarketplaceProps) {
|
||||
const normalizedQuery = query.trim()
|
||||
const showCollections = category === 'all' && !normalizedQuery && view !== 'search'
|
||||
const [
|
||||
{ t: tPlugin },
|
||||
{ t: tApp },
|
||||
{ t: tExplore },
|
||||
{ t: tPluginTags },
|
||||
collectionsResult,
|
||||
searchResult,
|
||||
banners,
|
||||
] = await Promise.all([
|
||||
getTranslation(locale, 'plugin'),
|
||||
getTranslation(locale, 'app'),
|
||||
getTranslation(locale, 'explore'),
|
||||
getTranslation(locale, 'pluginTags'),
|
||||
showCollections ? getMarketplaceTemplateCollectionsAndTemplates() : Promise.resolve(null),
|
||||
showCollections
|
||||
? Promise.resolve(null)
|
||||
: searchMarketplaceTemplates({
|
||||
category,
|
||||
query: normalizedQuery,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
}),
|
||||
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),
|
||||
}
|
||||
const templates = filterTemplatesForLocale(searchResult?.templates ?? [], locale)
|
||||
const hasVisibleCollections =
|
||||
collectionsResult?.collections.some(
|
||||
(collection) =>
|
||||
filterTemplatesForLocale(
|
||||
collectionsResult.templatesByCollection[collection.name] ?? [],
|
||||
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)
|
||||
|
||||
return (
|
||||
<HomeStickyStateProvider>
|
||||
<div className="flex min-h-full w-full flex-col bg-background-default">
|
||||
<HomeHeader
|
||||
activeTab="templates"
|
||||
actions={
|
||||
<div className="p-0.5">
|
||||
<AccountSection compact />
|
||||
</div>
|
||||
}
|
||||
catalogLabels={{ plugins: pluginsLabel, templates: templatesLabel }}
|
||||
isMarketplacePlatform={false}
|
||||
/>
|
||||
<div className="relative flex w-full flex-col">
|
||||
<HomeHero
|
||||
isMarketplacePlatform={false}
|
||||
title={templatesLabel}
|
||||
subtitle={tExplore('apps.description' as never)}
|
||||
/>
|
||||
<HomeSearch>
|
||||
<TemplateSearchForm
|
||||
action={category === 'all' ? '/templates' : `/templates/${category}`}
|
||||
placeholder={tApp('newAppFromTemplate.searchAllTemplate' as never)}
|
||||
query={query}
|
||||
/>
|
||||
</HomeSearch>
|
||||
{banners.length > 0 && (
|
||||
<>
|
||||
<div aria-hidden="true" className="h-12 shrink-0" />
|
||||
<HomeTrending banners={banners} isMarketplacePlatform={false} />
|
||||
</>
|
||||
)}
|
||||
<HomeCatalogNavigation
|
||||
catalogTabs={
|
||||
<HomeCatalogTabs
|
||||
activeTab="templates"
|
||||
isMarketplacePlatform={false}
|
||||
labels={{ plugins: pluginsLabel, templates: templatesLabel }}
|
||||
/>
|
||||
}
|
||||
catalogCategories={
|
||||
<TemplateCategoryNavigation
|
||||
activeCategory={category}
|
||||
ariaLabel={tPlugin('allCategories' as never)}
|
||||
labels={categoryLabels}
|
||||
query={query}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<main
|
||||
className={cn(
|
||||
'relative flex grow flex-col bg-background-default px-8 py-2',
|
||||
styles.catalogContent,
|
||||
)}
|
||||
>
|
||||
{collectionsResult ? (
|
||||
hasVisibleCollections ? (
|
||||
<TemplateCollectionList
|
||||
becomePartnerText={tPlugin('marketplace.becomePartner' as never)}
|
||||
collections={collectionsResult.collections}
|
||||
locale={locale}
|
||||
partnerText={partnerText}
|
||||
templatesByCollection={collectionsResult.templatesByCollection}
|
||||
viewMoreText={tPlugin('marketplace.viewMore' as never)}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState>{tApp('newApp.noTemplateFound' as never)}</EmptyState>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-5 text-right text-sm text-text-tertiary">
|
||||
{tExplore('apps.resultNum' as never, { num: searchResult?.total ?? 0 })}
|
||||
</div>
|
||||
{templates.length > 0 ? (
|
||||
<TemplateGrid partnerText={partnerText} templates={templates} />
|
||||
) : (
|
||||
<EmptyState>{tApp('newApp.noTemplateFound' as never)}</EmptyState>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</HomeStickyStateProvider>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,85 @@
|
||||
import type { MarketplaceTemplate } from '@dify/contracts/marketplace'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import Partner from '@/app/components/plugins/base/badges/partner'
|
||||
import { MARKETPLACE_API_PREFIX } from '@/config'
|
||||
import Link from '@/next/link'
|
||||
import { formatNumberAbbreviated } from '@/utils/format'
|
||||
import { getIconFromMarketPlace } from '@/utils/get-icon'
|
||||
|
||||
type TemplateCardProps = {
|
||||
template: MarketplaceTemplate
|
||||
className?: string
|
||||
partnerText: string
|
||||
}
|
||||
|
||||
const MAX_VISIBLE_PLUGIN_DEPENDENCIES = 7
|
||||
|
||||
export default function TemplateCard({ template, className, partnerText }: TemplateCardProps) {
|
||||
const publisher =
|
||||
template.publisher_handle || template.publisher_unique_handle || template.creator_email || ''
|
||||
const visiblePlugins = template.deps_plugins?.slice(0, MAX_VISIBLE_PLUGIN_DEPENDENCIES) ?? []
|
||||
const remainingPluginCount = Math.max(
|
||||
0,
|
||||
(template.deps_plugins?.length ?? 0) - MAX_VISIBLE_PLUGIN_DEPENDENCIES,
|
||||
)
|
||||
const imageUrl = template.icon_file_key
|
||||
? `${MARKETPLACE_API_PREFIX}/templates/${template.id}/icon`
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
'relative flex h-full flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg pb-3 shadow-xs hover:bg-components-panel-on-panel-item-bg-hover',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex shrink-0 items-center gap-3 px-4 pt-4 pb-2">
|
||||
<AppIcon
|
||||
size="large"
|
||||
iconType={imageUrl ? 'image' : 'emoji'}
|
||||
icon={imageUrl ? undefined : template.icon || '📄'}
|
||||
imageUrl={imageUrl}
|
||||
background={template.icon_background}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col justify-center gap-0.5">
|
||||
<div className="flex items-center">
|
||||
<Link
|
||||
href={`/apps?template-id=${encodeURIComponent(template.id)}`}
|
||||
className="truncate system-md-medium text-text-primary after:absolute after:inset-0"
|
||||
>
|
||||
{template.template_name}
|
||||
</Link>
|
||||
{template.badges?.includes('partner') && (
|
||||
<Partner className="relative z-[1] ml-0.5 size-4 shrink-0" text={partnerText} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 system-xs-regular text-text-tertiary">
|
||||
{publisher && <span className="truncate">{publisher}</span>}
|
||||
{publisher && <span>·</span>}
|
||||
<span>{formatNumberAbbreviated(template.usage_count)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-8 px-4 pt-1 pb-2 system-xs-regular text-text-secondary">
|
||||
<p className="line-clamp-2" title={template.overview}>
|
||||
{template.overview}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-auto flex min-h-7 items-center gap-1 px-4 py-1">
|
||||
{visiblePlugins.map((pluginId) => (
|
||||
<img
|
||||
key={pluginId}
|
||||
className="size-6 rounded-md border-[0.5px] border-effects-icon-border object-cover"
|
||||
src={getIconFromMarketPlace(pluginId)}
|
||||
alt=""
|
||||
title={pluginId}
|
||||
/>
|
||||
))}
|
||||
{remainingPluginCount > 0 && (
|
||||
<span className="system-xs-regular text-text-tertiary">+{remainingPluginCount}</span>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,153 @@
|
||||
'use client'
|
||||
|
||||
import type {
|
||||
MarketplaceTemplate,
|
||||
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 TemplateCard from './template-card'
|
||||
import { filterTemplatesForLocale, getTemplateCollectionText } from './template-language'
|
||||
|
||||
const BECOME_PARTNER_URL = 'https://share-na2.hsforms.com/1NiS4r9lsSqGcuNBB77DeEQ40s9fk'
|
||||
const PARTNER_COLLECTION_NAMES = new Set(['partners', 'partner-template', 'Partner Template'])
|
||||
|
||||
type TemplateCollectionListProps = {
|
||||
becomePartnerText: string
|
||||
collections: MarketplaceTemplateCollection[]
|
||||
locale: string
|
||||
partnerText: string
|
||||
templatesByCollection: Record<string, MarketplaceTemplate[]>
|
||||
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
|
||||
|
||||
if (collectionSearch?.query) searchParams.set('q', collectionSearch.query)
|
||||
if (collectionSearch?.sort_by) searchParams.set('sort_by', collectionSearch.sort_by)
|
||||
if (collectionSearch?.sort_order) searchParams.set('sort_order', collectionSearch.sort_order)
|
||||
|
||||
return `/templates/all?${searchParams.toString()}`
|
||||
}
|
||||
|
||||
export default function TemplateCollectionList({
|
||||
becomePartnerText,
|
||||
collections,
|
||||
locale,
|
||||
partnerText,
|
||||
templatesByCollection,
|
||||
viewMoreText,
|
||||
}: TemplateCollectionListProps) {
|
||||
const viewportWidth = useSyncExternalStore(
|
||||
subscribeToViewport,
|
||||
getViewportWidth,
|
||||
getServerViewportWidth,
|
||||
)
|
||||
const itemsPerPage = getCarouselItemsPerPage(viewportWidth)
|
||||
|
||||
return collections.map((collection) => {
|
||||
const templates = filterTemplatesForLocale(templatesByCollection[collection.name] ?? [], locale)
|
||||
|
||||
if (!templates.length) return null
|
||||
|
||||
const carouselPages = Array.from(
|
||||
{ length: Math.ceil(templates.length / itemsPerPage) },
|
||||
(_, pageIndex) => {
|
||||
const pageTemplates = templates.slice(
|
||||
pageIndex * itemsPerPage,
|
||||
(pageIndex + 1) * itemsPerPage,
|
||||
)
|
||||
|
||||
return {
|
||||
id: `${collection.name}-${itemsPerPage}-${pageIndex}`,
|
||||
content: (
|
||||
<div className={cn(GRID_CLASS)}>
|
||||
{pageTemplates.map((template) => (
|
||||
<div key={template.id} className="min-w-0 *:w-full">
|
||||
<TemplateCard partnerText={partnerText} template={template} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
},
|
||||
)
|
||||
const isPartnerCollection = PARTNER_COLLECTION_NAMES.has(collection.name)
|
||||
|
||||
return (
|
||||
<section key={collection.name} className="py-3">
|
||||
<div className="mb-2 flex items-end justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h2 className="title-xl-semi-bold text-text-primary">
|
||||
{getTemplateCollectionText(collection.label, locale)}
|
||||
</h2>
|
||||
<div className="flex flex-wrap items-center gap-x-2 system-xs-regular text-text-tertiary">
|
||||
<span>{getTemplateCollectionText(collection.description, locale)}</span>
|
||||
{isPartnerCollection && (
|
||||
<>
|
||||
<span className="text-divider-regular">|</span>
|
||||
<a
|
||||
href={BECOME_PARTNER_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-x-0.5 text-text-accent hover:underline"
|
||||
>
|
||||
<span>{becomePartnerText}</span>
|
||||
<span aria-hidden className="i-ri-external-link-line size-3" />
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{collection.searchable && (
|
||||
<Link
|
||||
href={getViewMoreHref(collection)}
|
||||
className="flex shrink-0 items-center system-xs-medium text-text-accent hover:underline"
|
||||
>
|
||||
{viewMoreText}
|
||||
<span aria-hidden className="i-ri-arrow-right-s-line size-4" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{collection.searchable ? (
|
||||
<div className={GRID_CLASS}>
|
||||
{templates.slice(0, 4).map((template) => (
|
||||
<TemplateCard key={template.id} partnerText={partnerText} template={template} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Carousel
|
||||
pages={carouselPages}
|
||||
showNavigation
|
||||
showPagination
|
||||
autoPlay
|
||||
autoPlayInterval={5000}
|
||||
pauseWhenOffscreen
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
})
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
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'
|
||||
}
|
||||
|
||||
export function filterTemplatesForLocale<
|
||||
T extends Pick<MarketplaceTemplate, 'preferred_languages'>,
|
||||
>(templates: T[], locale: string) {
|
||||
const languageFamily = getTemplateLanguageFamily(locale)
|
||||
|
||||
return templates.filter((template) => {
|
||||
const preferredLanguages = (template.preferred_languages ?? []).map((language) =>
|
||||
language.toLowerCase(),
|
||||
)
|
||||
|
||||
if (languageFamily === 'other') {
|
||||
return !preferredLanguages.some(
|
||||
(language) =>
|
||||
language.startsWith('en') || language.startsWith('zh') || language.startsWith('ja'),
|
||||
)
|
||||
}
|
||||
|
||||
return preferredLanguages.some((language) => language.startsWith(languageFamily))
|
||||
})
|
||||
}
|
||||
|
||||
export function getTemplateCollectionText(value: Record<string, string>, locale: string) {
|
||||
const localeKey = locale.replace('-', '_')
|
||||
|
||||
return value[localeKey] || value.en_US || Object.values(value)[0] || ''
|
||||
}
|
||||
79
web/service/marketplace-template-discovery.spec.ts
Normal file
79
web/service/marketplace-template-discovery.spec.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
getMarketplaceTemplateCollectionsAndTemplates,
|
||||
searchMarketplaceTemplates,
|
||||
} from './marketplace-template-discovery'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
templateCollections: vi.fn(),
|
||||
templateCollectionTemplates: vi.fn(),
|
||||
templateSearch: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
marketplaceClient: {
|
||||
templateCollections: (...args: unknown[]) => mocks.templateCollections(...args),
|
||||
templateCollectionTemplates: (...args: unknown[]) => mocks.templateCollectionTemplates(...args),
|
||||
templateSearch: (...args: unknown[]) => mocks.templateSearch(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('marketplace template discovery', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('loads each template collection and isolates a failed collection', async () => {
|
||||
mocks.templateCollections.mockResolvedValue({
|
||||
data: {
|
||||
collections: [
|
||||
{ name: 'featured', label: {}, description: {}, priority: 1 },
|
||||
{ name: 'partners', label: {}, description: {}, priority: 2 },
|
||||
],
|
||||
},
|
||||
})
|
||||
mocks.templateCollectionTemplates
|
||||
.mockResolvedValueOnce({ data: { templates: [{ id: 'template-1' }] } })
|
||||
.mockRejectedValueOnce(new Error('Unavailable'))
|
||||
|
||||
const result = await getMarketplaceTemplateCollectionsAndTemplates()
|
||||
|
||||
expect(mocks.templateCollections).toHaveBeenCalledWith({
|
||||
query: { page: 1, page_size: 100 },
|
||||
})
|
||||
expect(mocks.templateCollectionTemplates).toHaveBeenNthCalledWith(1, {
|
||||
params: { collectionName: 'featured' },
|
||||
body: { limit: 100 },
|
||||
})
|
||||
expect(result.templatesByCollection).toEqual({
|
||||
featured: [{ id: 'template-1' }],
|
||||
partners: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('sends category searches through the Marketplace contract', async () => {
|
||||
mocks.templateSearch.mockResolvedValue({
|
||||
data: {
|
||||
templates: [{ id: 'template-1' }],
|
||||
total: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const result = await searchMarketplaceTemplates({
|
||||
category: 'marketing',
|
||||
query: 'campaign',
|
||||
})
|
||||
|
||||
expect(mocks.templateSearch).toHaveBeenCalledWith({
|
||||
body: {
|
||||
page: 1,
|
||||
page_size: 40,
|
||||
query: 'campaign',
|
||||
sort_by: 'usage_count',
|
||||
sort_order: 'DESC',
|
||||
categories: ['marketing'],
|
||||
},
|
||||
})
|
||||
expect(result).toEqual({ templates: [{ id: 'template-1' }], total: 1 })
|
||||
})
|
||||
})
|
||||
85
web/service/marketplace-template-discovery.ts
Normal file
85
web/service/marketplace-template-discovery.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import type {
|
||||
MarketplaceTemplate,
|
||||
MarketplaceTemplateCollection,
|
||||
} from '@dify/contracts/marketplace'
|
||||
import { marketplaceClient } from './client'
|
||||
|
||||
export type MarketplaceTemplateCollectionsResult = {
|
||||
collections: MarketplaceTemplateCollection[]
|
||||
templatesByCollection: Record<string, MarketplaceTemplate[]>
|
||||
}
|
||||
|
||||
type SearchMarketplaceTemplatesOptions = {
|
||||
category: string
|
||||
query: string
|
||||
sortBy?: string
|
||||
sortOrder?: string
|
||||
}
|
||||
|
||||
const EMPTY_COLLECTIONS_RESULT: MarketplaceTemplateCollectionsResult = {
|
||||
collections: [],
|
||||
templatesByCollection: {},
|
||||
}
|
||||
|
||||
export async function getMarketplaceTemplateCollectionsAndTemplates(): Promise<MarketplaceTemplateCollectionsResult> {
|
||||
try {
|
||||
const response = await marketplaceClient.templateCollections({
|
||||
query: {
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
},
|
||||
})
|
||||
const collections = response.data?.collections ?? []
|
||||
const entries = await Promise.all(
|
||||
collections.map(async (collection) => {
|
||||
try {
|
||||
const collectionResponse = await marketplaceClient.templateCollectionTemplates({
|
||||
params: { collectionName: collection.name },
|
||||
body: { limit: 100 },
|
||||
})
|
||||
|
||||
return [collection.name, collectionResponse.data?.templates ?? []] as const
|
||||
} catch {
|
||||
return [collection.name, []] as const
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
collections,
|
||||
templatesByCollection: Object.fromEntries(entries),
|
||||
}
|
||||
} catch {
|
||||
return EMPTY_COLLECTIONS_RESULT
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchMarketplaceTemplates({
|
||||
category,
|
||||
query,
|
||||
sortBy = 'usage_count',
|
||||
sortOrder = 'DESC',
|
||||
}: SearchMarketplaceTemplatesOptions) {
|
||||
try {
|
||||
const response = await marketplaceClient.templateSearch({
|
||||
body: {
|
||||
page: 1,
|
||||
page_size: 40,
|
||||
query,
|
||||
sort_by: sortBy,
|
||||
sort_order: sortOrder,
|
||||
...(category === 'all' ? {} : { categories: [category] }),
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
templates: response.data?.templates ?? [],
|
||||
total: response.data?.total ?? 0,
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
templates: [],
|
||||
total: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user