fix(web): paginate creator inventory with load more

SSR only the first publisher page instead of five sequential fetches,
and let the profile load remaining plugins and templates on demand.
Harden social_links and deps_plugins so dirty API payloads cannot crash
the creator page.
This commit is contained in:
CodingOnStar 2026-09-02 15:22:17 +08:00
parent 17135522af
commit ea6c67b231
34 changed files with 530 additions and 132 deletions

View File

@ -5,6 +5,20 @@ import { describe, expect, it, vi } from 'vitest'
import { renderWithNuqs } from '@/test/nuqs-testing'
import CreatorContent from '../creator-content'
const publisherMocks = vi.hoisted(() => ({
fetchPublisherPluginPage: vi.fn(),
fetchPublisherTemplatePage: vi.fn(),
}))
vi.mock('../publisher', async (importOriginal) => {
const actual = await importOriginal<typeof import('../publisher')>()
return {
...actual,
fetchPublisherPluginPage: publisherMocks.fetchPublisherPluginPage,
fetchPublisherTemplatePage: publisherMocks.fetchPublisherTemplatePage,
}
})
vi.mock('#i18n', async () => {
const { withSelectorKey } = await import('@/test/i18n-mock')
const translations: Record<string, string> = {
@ -17,6 +31,8 @@ vi.mock('#i18n', async () => {
'marketplace.creatorProfile.sort.desc': 'Sort descending',
'marketplace.creatorProfile.type.plugin': 'Plugin',
'marketplace.creatorProfile.type.template': 'Template',
'marketplace.creatorProfile.loadMore': 'Load more',
'marketplace.creatorProfile.loadMoreFailed': "Couldn't load more creations.",
}
return {
@ -59,6 +75,10 @@ const creations = [
const cardNames = () => screen.getAllByRole('link').map((link) => link.getAttribute('aria-label'))
describe('CreatorContent', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('writes sort into the URL and reorders the current cards', async () => {
const user = userEvent.setup()
const { onUrlUpdate } = renderWithNuqs(
@ -91,4 +111,53 @@ describe('CreatorContent', () => {
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('sort_by')).toBe('popularity')
})
})
it('loads the next publisher pages when more creations exist', async () => {
const user = userEvent.setup()
publisherMocks.fetchPublisherPluginPage.mockResolvedValue({
items: [
{
type: 'plugin',
org: 'dify',
name: 'delta',
labels: { 'en-US': 'Delta' },
brief: { 'en-US': 'Delta plugin' },
install_count: 4,
created_at: '2026-01-04T00:00:00Z',
updated_at: '2026-02-04T00:00:00Z',
},
],
hasMore: false,
})
publisherMocks.fetchPublisherTemplatePage.mockResolvedValue({ items: [], hasMore: false })
renderWithNuqs(
<CreatorContent
creations={creations}
locale="en-US"
inventory={{
uniqueHandle: 'scarlettmao',
pluginHasMore: true,
templateHasMore: false,
pluginNextPage: 2,
templateNextPage: 2,
}}
getCreationAction={(creation) => ({ type: 'link', href: `/creation/${creation.id}` })}
/>,
)
await user.click(screen.getByRole('button', { name: 'Load more' }))
await waitFor(() => {
expect(cardNames()).toContain('Delta')
})
expect(publisherMocks.fetchPublisherPluginPage).toHaveBeenCalledWith({
uniqueHandle: 'scarlettmao',
page: 2,
sortField: 'updatedAt',
sortOrder: 'desc',
})
expect(publisherMocks.fetchPublisherTemplatePage).not.toHaveBeenCalled()
expect(screen.queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument()
})
})

View File

@ -164,35 +164,50 @@ describe('loadCreatorProfile', () => {
expect(loaded?.viewModel.creations.map(({ kind }) => kind)).toEqual(['template', 'plugin'])
})
it('fetches remaining publisher pages until the reported total is loaded', async () => {
const extraPlugin = {
...plugin,
name: 'extra',
plugin_id: 'dify/extra',
} as MarketplacePlugin
mocks.publisherPlugins
.mockResolvedValueOnce({
data: { plugins: [plugin], total: 2 },
})
.mockResolvedValueOnce({
data: { plugins: [extraPlugin], total: 2 },
})
it('loads only the first publisher page and reports remaining inventory', async () => {
mocks.publisherPlugins.mockResolvedValue({
data: {
plugins: Array.from({ length: 40 }, (_, index) => ({ ...plugin, name: `p-${index}` })),
total: 90,
},
})
mocks.publisherTemplates.mockResolvedValue({
data: { templates: [template], total: 1 },
})
const loaded = await loadCreatorProfile({
uniqueHandle: 'paged-creator',
locale: 'en-US',
})
expect(mocks.publisherPlugins).toHaveBeenNthCalledWith(1, {
expect(mocks.publisherPlugins).toHaveBeenCalledOnce()
expect(mocks.publisherPlugins).toHaveBeenCalledWith({
params: { uniqueHandle: 'paged-creator' },
query: { page: 1, page_size: 40, sort_by: 'version_updated_at', sort_order: 'DESC' },
})
expect(mocks.publisherPlugins).toHaveBeenNthCalledWith(2, {
params: { uniqueHandle: 'paged-creator' },
query: { page: 2, page_size: 40, sort_by: 'version_updated_at', sort_order: 'DESC' },
expect(loaded?.inventory).toMatchObject({
uniqueHandle: 'paged-creator',
pluginHasMore: true,
templateHasMore: false,
pluginNextPage: 2,
})
expect(loaded?.pluginsByCreationId['plugin:dify/search']).toBeDefined()
expect(loaded?.pluginsByCreationId['plugin:dify/extra']).toBeDefined()
expect(loaded?.viewModel.creations).toHaveLength(41)
})
it('does not treat a full first page as the complete inventory when total is missing', async () => {
mocks.publisherPlugins.mockResolvedValue({
data: {
plugins: Array.from({ length: 40 }, (_, index) => ({ ...plugin, name: `p-${index}` })),
},
})
const loaded = await loadCreatorProfile({
uniqueHandle: 'uncounted-creator',
locale: 'en-US',
})
expect(mocks.publisherPlugins).toHaveBeenCalledOnce()
expect(loaded?.inventory.pluginHasMore).toBe(true)
})
it('keeps successful creations when one publisher request fails', async () => {

View File

@ -99,6 +99,13 @@ const loadedProfile: LoadedCreatorProfile = {
templatesByCreationId: {
'template:template-one': template,
},
inventory: {
uniqueHandle: 'creator',
pluginHasMore: false,
templateHasMore: false,
pluginNextPage: 2,
templateNextPage: 2,
},
}
vi.mock('#i18n', async () => {

View File

@ -192,4 +192,33 @@ describe('creator profile model', () => {
expect(normalizeCreatorSocialLink('data:text/html,bad')).toBeNull()
expect(normalizeCreatorSocialLink('mailto:test@example.com')).toBeNull()
})
it('ignores non-string social links and template dependencies instead of throwing', () => {
expect(normalizeCreatorSocialLink({ href: 'https://x.com/x' })).toBeNull()
expect(normalizeCreatorSocialLink(null)).toBeNull()
const viewModel = adaptCreatorProfile({
creator: {
...creator,
social_links: [{ href: 'https://x.com/x' }, 'github.com/evanz'] as unknown as string[],
},
kind: 'individual',
locale: 'en-US',
avatarUrl: '/avatar',
backgroundUrl: '/background',
plugins: [],
templates: [
{
...template,
deps_plugins: [null, 'dify/search', ''] as unknown as string[],
},
],
resolvePluginIcon: () => '/plugin-icon',
resolveTemplateIcon: () => '',
resolveDependencyIcon: (id) => `/dependency/${id}`,
})
expect(viewModel.profile.socialLinks).toEqual([expect.objectContaining({ platform: 'github' })])
expect(viewModel.creations[0]?.dependencyIcons).toEqual(['/dependency/dify/search'])
})
})

View File

@ -1,11 +1,14 @@
'use client'
import type { MarketplacePlugin, MarketplaceTemplate } from '@dify/contracts/marketplace'
import type {
CreatorCreation,
CreatorCreationAction,
CreatorInventory,
CreatorSortField,
CreatorSortOrder,
} from './model'
import type { Plugin } from '@/app/components/plugins/types'
import {
DropdownMenu,
DropdownMenuContent,
@ -15,7 +18,7 @@ import {
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { parseAsStringEnum, useQueryStates } from 'nuqs'
import { useMemo } from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from '#i18n'
import CreationCard from './creation-card'
import {
@ -24,10 +27,17 @@ import {
DEFAULT_CREATOR_SORT_ORDER,
sortCreatorCreations,
} from './model'
import { fetchPublisherPluginPage, fetchPublisherTemplatePage, toCreatorRecords } from './publisher'
type CreatorContentProps = {
creations: CreatorCreation[]
getCreationAction: (creation: CreatorCreation) => CreatorCreationAction
inventory?: CreatorInventory
locale?: string
onRecordsLoaded?: (records: {
pluginsByCreationId: Record<string, Plugin>
templatesByCreationId: Record<string, MarketplaceTemplate>
}) => void
}
const sortSearchOptions = { history: 'replace' as const, shallow: false, scroll: false }
@ -40,11 +50,34 @@ const creatorSortSearchParsers = {
),
}
export default function CreatorContent({ creations, getCreationAction }: CreatorContentProps) {
export default function CreatorContent({
creations,
getCreationAction,
inventory,
locale = 'en-US',
onRecordsLoaded,
}: CreatorContentProps) {
const { t } = useTranslation()
const [sort, setSort] = useQueryStates(creatorSortSearchParsers, sortSearchOptions)
const sortField = sort.sort_by
const sortOrder = sort.sort_order
const [sourceCreations, setSourceCreations] = useState(creations)
const [loadedCreations, setLoadedCreations] = useState(creations)
const [pluginHasMore, setPluginHasMore] = useState(inventory?.pluginHasMore ?? false)
const [templateHasMore, setTemplateHasMore] = useState(inventory?.templateHasMore ?? false)
const [pluginNextPage, setPluginNextPage] = useState(inventory?.pluginNextPage ?? 2)
const [templateNextPage, setTemplateNextPage] = useState(inventory?.templateNextPage ?? 2)
const [isLoadingMore, setIsLoadingMore] = useState(false)
const [loadMoreFailed, setLoadMoreFailed] = useState(false)
if (creations !== sourceCreations) {
setSourceCreations(creations)
setLoadedCreations(creations)
setPluginHasMore(inventory?.pluginHasMore ?? false)
setTemplateHasMore(inventory?.templateHasMore ?? false)
setPluginNextPage(inventory?.pluginNextPage ?? 2)
setTemplateNextPage(inventory?.templateNextPage ?? 2)
setLoadMoreFailed(false)
}
const sortOptions: Array<{ value: CreatorSortField; label: string }> = [
{
value: 'updatedAt',
@ -61,10 +94,61 @@ export default function CreatorContent({ creations, getCreationAction }: Creator
]
const selectedSort = sortOptions.find((option) => option.value === sortField) ?? sortOptions[0]!
const sortedCreations = useMemo(
() => sortCreatorCreations(creations, sortField, sortOrder),
[creations, sortField, sortOrder],
() => sortCreatorCreations(loadedCreations, sortField, sortOrder),
[loadedCreations, sortField, sortOrder],
)
const nextSortOrder = sortOrder === 'desc' ? 'asc' : 'desc'
const hasMore = pluginHasMore || templateHasMore
const uniqueHandle = inventory?.uniqueHandle
const loadMore = async () => {
if (!uniqueHandle || isLoadingMore || !hasMore) return
setIsLoadingMore(true)
setLoadMoreFailed(false)
try {
const [pluginPage, templatePage] = await Promise.all([
pluginHasMore
? fetchPublisherPluginPage({
uniqueHandle,
page: pluginNextPage,
sortField,
sortOrder,
})
: Promise.resolve({ items: [] as MarketplacePlugin[], hasMore: false }),
templateHasMore
? fetchPublisherTemplatePage({
uniqueHandle,
page: templateNextPage,
sortField,
sortOrder,
})
: Promise.resolve({ items: [] as MarketplaceTemplate[], hasMore: false }),
])
const records = toCreatorRecords({
locale,
plugins: pluginPage.items,
templates: templatePage.items,
})
setLoadedCreations((current) => {
const seen = new Set(current.map((creation) => creation.id))
return [...current, ...records.creations.filter((creation) => !seen.has(creation.id))]
})
if (pluginHasMore) {
setPluginHasMore(pluginPage.hasMore)
setPluginNextPage((page) => page + 1)
}
if (templateHasMore) {
setTemplateHasMore(templatePage.hasMore)
setTemplateNextPage((page) => page + 1)
}
onRecordsLoaded?.(records)
} catch {
setLoadMoreFailed(true)
} finally {
setIsLoadingMore(false)
}
}
return (
<section
@ -151,6 +235,27 @@ export default function CreatorContent({ creations, getCreationAction }: Creator
{t(($) => $['marketplace.creatorProfile.empty'], { ns: 'plugin' })}
</div>
)}
{hasMore && (
<div className="flex w-full flex-col items-center gap-2 pt-6">
<button
type="button"
aria-busy={isLoadingMore || undefined}
disabled={isLoadingMore}
className="flex h-8 items-center rounded-lg px-3 system-sm-medium text-text-secondary outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:opacity-50"
onClick={() => {
void loadMore()
}}
>
{t(($) => $['marketplace.creatorProfile.loadMore'], { ns: 'plugin' })}
</button>
{loadMoreFailed && (
<p className="system-xs-regular text-text-destructive">
{t(($) => $['marketplace.creatorProfile.loadMoreFailed'], { ns: 'plugin' })}
</p>
)}
</div>
)}
</section>
)
}

View File

@ -1,42 +1,24 @@
import type {
MarketplaceCreator,
MarketplaceOrganization,
MarketplacePlugin,
MarketplaceTemplate,
} from '@dify/contracts/marketplace'
import type { MarketplaceCreator, MarketplaceOrganization } from '@dify/contracts/marketplace'
import type { CreatorSortField, CreatorSortOrder, LoadedCreatorProfile } from './model'
import { cache } from 'react'
import { MARKETPLACE_API_PREFIX } from '@/config'
import { marketplaceClient } from '@/service/client'
import { getFormattedPlugin, getPluginIconInMarketplace } from '../utils'
import { getPluginIconInMarketplace } from '../utils'
import {
adaptCreatorProfile,
parseCreatorSortField,
parseCreatorSortOrder,
sortCreatorCreations,
toPublisherSortQuery,
} from './model'
import {
fetchPublisherPluginPage,
fetchPublisherTemplatePage,
getDependencyIcon,
getTemplateIcon,
toCreatorRecords,
} from './publisher'
import 'server-only'
const PAGE_SIZE = 40
const MAX_PAGES = 5
const fetchAllPublisherPages = async <T>(
fetchPage: (page: number) => Promise<{ items: T[]; total?: number }>,
) => {
const first = await fetchPage(1)
const items = [...first.items]
const total = first.total ?? items.length
for (let page = 2; page <= MAX_PAGES && items.length < total; page++) {
const next = await fetchPage(page)
if (next.items.length === 0) break
items.push(...next.items)
}
return items
}
const mapOrganizationToCreator = (
organization: MarketplaceOrganization,
uniqueHandle: string,
@ -73,50 +55,6 @@ const getPublisher = async (uniqueHandle: string, publisherType?: string) => {
return response.data?.creator
}
const getPublisherPlugins = async (
uniqueHandle: string,
sortField: CreatorSortField,
sortOrder: CreatorSortOrder,
) => {
const { plugins } = toPublisherSortQuery(sortField, sortOrder)
return fetchAllPublisherPages(async (page) => {
const response = await marketplaceClient.publisherPlugins({
params: { uniqueHandle },
query: { page, page_size: PAGE_SIZE, ...plugins },
})
return {
items: response.data?.plugins ?? [],
total: response.data?.total,
}
})
}
const getPublisherTemplates = async (
uniqueHandle: string,
sortField: CreatorSortField,
sortOrder: CreatorSortOrder,
) => {
const { templates } = toPublisherSortQuery(sortField, sortOrder)
return fetchAllPublisherPages(async (page) => {
const response = await marketplaceClient.publisherTemplates({
params: { uniqueHandle },
query: { page, page_size: PAGE_SIZE, ...templates },
})
return {
items: response.data?.templates ?? [],
total: response.data?.total,
}
})
}
const getTemplateIcon = (template: MarketplaceTemplate) =>
template.icon_file_key
? `${MARKETPLACE_API_PREFIX}/templates/${encodeURIComponent(template.id)}/icon`
: ''
const getDependencyIcon = (pluginId: string) =>
`${MARKETPLACE_API_PREFIX}/plugins/${pluginId.split('/').map(encodeURIComponent).join('/')}/icon`
const loadCreatorProfileCached = cache(
async (
uniqueHandle: string,
@ -127,18 +65,18 @@ const loadCreatorProfileCached = cache(
): Promise<LoadedCreatorProfile | null> => {
const [creatorResult, pluginsResult, templatesResult] = await Promise.allSettled([
getPublisher(uniqueHandle, publisherType),
getPublisherPlugins(uniqueHandle, sortField, sortOrder),
getPublisherTemplates(uniqueHandle, sortField, sortOrder),
fetchPublisherPluginPage({ uniqueHandle, page: 1, sortField, sortOrder }),
fetchPublisherTemplatePage({ uniqueHandle, page: 1, sortField, sortOrder }),
])
if (creatorResult.status === 'rejected') throw creatorResult.reason
const creator = creatorResult.value
if (!creator) return null
const plugins: MarketplacePlugin[] =
pluginsResult.status === 'fulfilled' ? pluginsResult.value : []
const templates: MarketplaceTemplate[] =
templatesResult.status === 'fulfilled' ? templatesResult.value : []
const plugins = pluginsResult.status === 'fulfilled' ? pluginsResult.value.items : []
const templates = templatesResult.status === 'fulfilled' ? templatesResult.value.items : []
const pluginPage = pluginsResult.status === 'fulfilled' ? pluginsResult.value : undefined
const templatePage = templatesResult.status === 'fulfilled' ? templatesResult.value : undefined
const kind = publisherType === 'organization' ? 'organization' : 'individual'
const resource = kind === 'organization' ? 'organizations' : 'creators'
const encodedHandle = encodeURIComponent(uniqueHandle)
@ -160,21 +98,22 @@ const loadCreatorProfileCached = cache(
resolveTemplateIcon: getTemplateIcon,
resolveDependencyIcon: getDependencyIcon,
})
const records = toCreatorRecords({ locale, plugins, templates })
return {
viewModel: {
...viewModel,
creations: sortCreatorCreations(viewModel.creations, sortField, sortOrder),
},
pluginsByCreationId: Object.fromEntries(
plugins.map((plugin) => [
`${plugin.type}:${plugin.org}/${plugin.name}`,
getFormattedPlugin(plugin),
]),
),
templatesByCreationId: Object.fromEntries(
templates.map((template) => [`template:${template.id}`, template]),
),
pluginsByCreationId: records.pluginsByCreationId,
templatesByCreationId: records.templatesByCreationId,
inventory: {
uniqueHandle,
pluginHasMore: pluginPage?.hasMore ?? false,
templateHasMore: templatePage?.hasMore ?? false,
pluginNextPage: 2,
templateNextPage: 2,
},
}
},
)

View File

@ -34,7 +34,18 @@ const normalizePlugin = (plugin: Plugin): Plugin => ({
export default function DifyCreatorProfile({ loadedProfile, locale }: DifyCreatorProfileProps) {
const router = useRouter()
const [selected, setSelected] = useState<SelectedCreation | null>(null)
const profilePlugins = Object.values(loadedProfile.pluginsByCreationId)
const [sourceProfile, setSourceProfile] = useState(loadedProfile)
const [pluginsByCreationId, setPluginsByCreationId] = useState(loadedProfile.pluginsByCreationId)
const [templatesByCreationId, setTemplatesByCreationId] = useState(
loadedProfile.templatesByCreationId,
)
if (loadedProfile !== sourceProfile) {
setSourceProfile(loadedProfile)
setPluginsByCreationId(loadedProfile.pluginsByCreationId)
setTemplatesByCreationId(loadedProfile.templatesByCreationId)
}
const profilePlugins = Object.values(pluginsByCreationId)
const pluginIds = useMemo(
() =>
Array.from(
@ -52,12 +63,12 @@ export default function DifyCreatorProfile({ loadedProfile, locale }: DifyCreato
const selectCreation = (creation: CreatorCreation) => {
if (creation.kind === 'plugin') {
const plugin = loadedProfile.pluginsByCreationId[creation.id]
const plugin = pluginsByCreationId[creation.id]
if (plugin) setSelected({ kind: 'plugin', plugin: normalizePlugin(plugin) })
return
}
const template = loadedProfile.templatesByCreationId[creation.id]
const template = templatesByCreationId[creation.id]
if (template) setSelected({ kind: 'template', template })
}
@ -82,6 +93,15 @@ export default function DifyCreatorProfile({ loadedProfile, locale }: DifyCreato
profile={loadedProfile.viewModel}
homeHref="/marketplace"
isMarketplacePlatform
inventory={loadedProfile.inventory}
locale={locale}
onRecordsLoaded={(records) => {
setPluginsByCreationId((current) => ({ ...current, ...records.pluginsByCreationId }))
setTemplatesByCreationId((current) => ({
...current,
...records.templatesByCreationId,
}))
}}
getCreationAction={(creation) => ({
type: 'select',
onSelect: () => selectCreation(creation),

View File

@ -98,10 +98,19 @@ export type CreatorProfileViewModel = {
creations: CreatorCreation[]
}
export type CreatorInventory = {
uniqueHandle: string
pluginHasMore: boolean
templateHasMore: boolean
pluginNextPage: number
templateNextPage: number
}
export type LoadedCreatorProfile = {
viewModel: CreatorProfileViewModel
pluginsByCreationId: Record<string, Plugin>
templatesByCreationId: Record<string, MarketplaceTemplate>
inventory: CreatorInventory
}
export type CreatorCreationAction =
@ -136,22 +145,26 @@ const toTimestamp = (value?: MarketplaceTimestamp | null) => {
return Number.isNaN(timestamp) ? 0 : timestamp
}
const firstLocalizedString = (value: object, keys: string[]) => {
for (const key of keys) {
const entry = (value as Record<string, unknown>)[key]
if (typeof entry === 'string' && entry) return entry
}
return (
Object.values(value).find((entry): entry is string => typeof entry === 'string' && !!entry) ??
''
)
}
const getCreatorLocalizedText = (
value: Partial<Record<string, string>> | string | undefined,
locale: string,
) => {
if (typeof value === 'string') return value
if (!value) return ''
if (!value || typeof value !== 'object') return ''
const normalizedLocale = locale.replace('-', '_')
return (
value[locale] ||
value[normalizedLocale] ||
value['en-US'] ||
value.en_US ||
Object.values(value).find(Boolean) ||
''
)
return firstLocalizedString(value, [locale, normalizedLocale, 'en-US', 'en_US'])
}
const getSocialPlatform = (hostname: string): CreatorSocialPlatform => {
@ -170,7 +183,8 @@ const getSocialPlatform = (hostname: string): CreatorSocialPlatform => {
return 'website'
}
export const normalizeCreatorSocialLink = (value: string): CreatorSocialLink | null => {
export const normalizeCreatorSocialLink = (value: unknown): CreatorSocialLink | null => {
if (typeof value !== 'string') return null
const trimmedValue = value.trim()
if (!trimmedValue) return null
@ -201,18 +215,22 @@ const getCreatorBadges = (creator: MarketplaceCreator) => {
return Array.from(badges)
}
export const adaptCreatorProfile = ({
creator,
kind,
export const adaptCreations = ({
locale,
avatarUrl,
backgroundUrl,
plugins,
templates,
resolvePluginIcon,
resolveTemplateIcon,
resolveDependencyIcon,
}: CreatorProfileAdapterInput): CreatorProfileViewModel => {
}: Pick<
CreatorProfileAdapterInput,
| 'locale'
| 'plugins'
| 'templates'
| 'resolvePluginIcon'
| 'resolveTemplateIcon'
| 'resolveDependencyIcon'
>): CreatorCreation[] => {
const pluginCreations = plugins.map((plugin): CreatorCreation => ({
id: `${plugin.type}:${plugin.org}/${plugin.name}`,
kind: 'plugin',
@ -240,7 +258,9 @@ export const adaptCreatorProfile = ({
const templateCreations = templates.map((template): CreatorCreation => {
const templateIcon = resolveTemplateIcon(template)
const dependencyIds = template.deps_plugins ?? []
const dependencyIds = (template.deps_plugins ?? []).filter(
(id): id is string => typeof id === 'string' && id.length > 0,
)
const publisher =
template.publisher_handle ||
template.publisher_unique_handle ||
@ -269,6 +289,21 @@ export const adaptCreatorProfile = ({
}
})
return [...pluginCreations, ...templateCreations]
}
export const adaptCreatorProfile = ({
creator,
kind,
locale,
avatarUrl,
backgroundUrl,
plugins,
templates,
resolvePluginIcon,
resolveTemplateIcon,
resolveDependencyIcon,
}: CreatorProfileAdapterInput): CreatorProfileViewModel => {
return {
profile: {
kind,
@ -283,7 +318,14 @@ export const adaptCreatorProfile = ({
.map(normalizeCreatorSocialLink)
.filter((link): link is CreatorSocialLink => link !== null),
},
creations: [...pluginCreations, ...templateCreations],
creations: adaptCreations({
locale,
plugins,
templates,
resolvePluginIcon,
resolveTemplateIcon,
resolveDependencyIcon,
}),
}
}

View File

@ -0,0 +1,102 @@
import type { MarketplacePlugin, MarketplaceTemplate } from '@dify/contracts/marketplace'
import type { CreatorCreation, CreatorSortField, CreatorSortOrder } from './model'
import type { Plugin } from '@/app/components/plugins/types'
import { MARKETPLACE_API_PREFIX } from '@/config'
import { marketplaceClient } from '@/service/client'
import { getFormattedPlugin, getPluginIconInMarketplace } from '../utils'
import { adaptCreations, toPublisherSortQuery } from './model'
export const CREATOR_PAGE_SIZE = 40
export type PublisherPage<T> = {
items: T[]
total?: number
hasMore: boolean
}
export const publisherPageHasMore = (page: number, itemCount: number, total?: number) =>
typeof total === 'number' ? page * CREATOR_PAGE_SIZE < total : itemCount === CREATOR_PAGE_SIZE
export const getTemplateIcon = (template: MarketplaceTemplate) =>
template.icon_file_key
? `${MARKETPLACE_API_PREFIX}/templates/${encodeURIComponent(template.id)}/icon`
: ''
export const getDependencyIcon = (pluginId: string) => {
if (!pluginId.includes('/')) return ''
return `${MARKETPLACE_API_PREFIX}/plugins/${pluginId.split('/').map(encodeURIComponent).join('/')}/icon`
}
export async function fetchPublisherPluginPage({
uniqueHandle,
page,
sortField,
sortOrder,
}: {
uniqueHandle: string
page: number
sortField: CreatorSortField
sortOrder: CreatorSortOrder
}): Promise<PublisherPage<MarketplacePlugin>> {
const { plugins } = toPublisherSortQuery(sortField, sortOrder)
const response = await marketplaceClient.publisherPlugins({
params: { uniqueHandle },
query: { page, page_size: CREATOR_PAGE_SIZE, ...plugins },
})
const items = response.data?.plugins ?? []
const total = response.data?.total
return { items, total, hasMore: publisherPageHasMore(page, items.length, total) }
}
export async function fetchPublisherTemplatePage({
uniqueHandle,
page,
sortField,
sortOrder,
}: {
uniqueHandle: string
page: number
sortField: CreatorSortField
sortOrder: CreatorSortOrder
}): Promise<PublisherPage<MarketplaceTemplate>> {
const { templates } = toPublisherSortQuery(sortField, sortOrder)
const response = await marketplaceClient.publisherTemplates({
params: { uniqueHandle },
query: { page, page_size: CREATOR_PAGE_SIZE, ...templates },
})
const items = response.data?.templates ?? []
const total = response.data?.total
return { items, total, hasMore: publisherPageHasMore(page, items.length, total) }
}
export const toCreatorRecords = ({
locale,
plugins,
templates,
}: {
locale: string
plugins: MarketplacePlugin[]
templates: MarketplaceTemplate[]
}): {
creations: CreatorCreation[]
pluginsByCreationId: Record<string, Plugin>
templatesByCreationId: Record<string, MarketplaceTemplate>
} => ({
creations: adaptCreations({
locale,
plugins,
templates,
resolvePluginIcon: getPluginIconInMarketplace,
resolveTemplateIcon: getTemplateIcon,
resolveDependencyIcon: getDependencyIcon,
}),
pluginsByCreationId: Object.fromEntries(
plugins.map((plugin) => [
`${plugin.type}:${plugin.org}/${plugin.name}`,
getFormattedPlugin(plugin),
]),
),
templatesByCreationId: Object.fromEntries(
templates.map((template) => [`template:${template.id}`, template]),
),
})

View File

@ -1,7 +1,14 @@
'use client'
import type { MarketplaceTemplate } from '@dify/contracts/marketplace'
import type { ReactNode } from 'react'
import type { CreatorCreation, CreatorCreationAction, CreatorProfileViewModel } from './model'
import type {
CreatorCreation,
CreatorCreationAction,
CreatorInventory,
CreatorProfileViewModel,
} from './model'
import type { Plugin } from '@/app/components/plugins/types'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from '#i18n'
import Link from '@/next/link'
@ -15,6 +22,12 @@ export type CreatorProfileViewProps = {
header?: ReactNode
homeHref: string
isMarketplacePlatform: boolean
inventory?: CreatorInventory
locale?: string
onRecordsLoaded?: (records: {
pluginsByCreationId: Record<string, Plugin>
templatesByCreationId: Record<string, MarketplaceTemplate>
}) => void
}
export default function CreatorProfileView({
@ -23,6 +36,9 @@ export default function CreatorProfileView({
header,
homeHref,
isMarketplacePlatform,
inventory,
locale,
onRecordsLoaded,
}: CreatorProfileViewProps) {
const { t } = useTranslation()
@ -79,7 +95,13 @@ export default function CreatorProfileView({
)}
>
<CreatorSidebar profile={profile.profile} />
<CreatorContent creations={profile.creations} getCreationAction={getCreationAction} />
<CreatorContent
creations={profile.creations}
getCreationAction={getCreationAction}
inventory={inventory}
locale={locale}
onRecordsLoaded={onRecordsLoaded}
/>
</div>
</div>
</main>

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "الأعمال",
"marketplace.creatorProfile.empty": "لا توجد أعمال بعد.",
"marketplace.creatorProfile.home": "الصفحة الرئيسية للسوق",
"marketplace.creatorProfile.loadMore": "Load more",
"marketplace.creatorProfile.loadMoreFailed": "Couldn't load more creations.",
"marketplace.creatorProfile.onTheWeb": "على الويب",
"marketplace.creatorProfile.organization": "منظمة",
"marketplace.creatorProfile.searchPlaceholder": "ابحث عن الإضافات والقوالب",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "Kreationen",
"marketplace.creatorProfile.empty": "Noch keine Kreationen.",
"marketplace.creatorProfile.home": "Marketplace-Startseite",
"marketplace.creatorProfile.loadMore": "Load more",
"marketplace.creatorProfile.loadMoreFailed": "Couldn't load more creations.",
"marketplace.creatorProfile.onTheWeb": "Im Web",
"marketplace.creatorProfile.organization": "Organisation",
"marketplace.creatorProfile.searchPlaceholder": "Plugins und Vorlagen suchen",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "Creations",
"marketplace.creatorProfile.empty": "No creations yet.",
"marketplace.creatorProfile.home": "Marketplace home",
"marketplace.creatorProfile.loadMore": "Load more",
"marketplace.creatorProfile.loadMoreFailed": "Couldn't load more creations.",
"marketplace.creatorProfile.onTheWeb": "On the web",
"marketplace.creatorProfile.organization": "Organization",
"marketplace.creatorProfile.searchPlaceholder": "Search plugins and templates",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "Creaciones",
"marketplace.creatorProfile.empty": "Aún no hay creaciones.",
"marketplace.creatorProfile.home": "Inicio del Marketplace",
"marketplace.creatorProfile.loadMore": "Load more",
"marketplace.creatorProfile.loadMoreFailed": "Couldn't load more creations.",
"marketplace.creatorProfile.onTheWeb": "En la web",
"marketplace.creatorProfile.organization": "Organización",
"marketplace.creatorProfile.searchPlaceholder": "Buscar plugins y plantillas",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "آثار",
"marketplace.creatorProfile.empty": "هنوز اثری وجود ندارد.",
"marketplace.creatorProfile.home": "خانه Marketplace",
"marketplace.creatorProfile.loadMore": "Load more",
"marketplace.creatorProfile.loadMoreFailed": "Couldn't load more creations.",
"marketplace.creatorProfile.onTheWeb": "در وب",
"marketplace.creatorProfile.organization": "سازمان",
"marketplace.creatorProfile.searchPlaceholder": "جستجوی افزونه و قالب",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "Créations",
"marketplace.creatorProfile.empty": "Aucune création pour le moment.",
"marketplace.creatorProfile.home": "Accueil du Marketplace",
"marketplace.creatorProfile.loadMore": "Load more",
"marketplace.creatorProfile.loadMoreFailed": "Couldn't load more creations.",
"marketplace.creatorProfile.onTheWeb": "Sur le web",
"marketplace.creatorProfile.organization": "Organisation",
"marketplace.creatorProfile.searchPlaceholder": "Rechercher des plugins et des modèles",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "रचनाएँ",
"marketplace.creatorProfile.empty": "अभी कोई रचना नहीं।",
"marketplace.creatorProfile.home": "Marketplace होम",
"marketplace.creatorProfile.loadMore": "Load more",
"marketplace.creatorProfile.loadMoreFailed": "Couldn't load more creations.",
"marketplace.creatorProfile.onTheWeb": "वेब पर",
"marketplace.creatorProfile.organization": "संगठन",
"marketplace.creatorProfile.searchPlaceholder": "प्लगिन और टेम्पलेट खोजें",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "Karya",
"marketplace.creatorProfile.empty": "Belum ada karya.",
"marketplace.creatorProfile.home": "Beranda Marketplace",
"marketplace.creatorProfile.loadMore": "Load more",
"marketplace.creatorProfile.loadMoreFailed": "Couldn't load more creations.",
"marketplace.creatorProfile.onTheWeb": "Di web",
"marketplace.creatorProfile.organization": "Organisasi",
"marketplace.creatorProfile.searchPlaceholder": "Cari plugin dan template",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "Creazioni",
"marketplace.creatorProfile.empty": "Nessuna creazione al momento.",
"marketplace.creatorProfile.home": "Home del Marketplace",
"marketplace.creatorProfile.loadMore": "Load more",
"marketplace.creatorProfile.loadMoreFailed": "Couldn't load more creations.",
"marketplace.creatorProfile.onTheWeb": "Sul web",
"marketplace.creatorProfile.organization": "Organizzazione",
"marketplace.creatorProfile.searchPlaceholder": "Cerca plugin e modelli",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "作品",
"marketplace.creatorProfile.empty": "作品はまだありません。",
"marketplace.creatorProfile.home": "Marketplace ホーム",
"marketplace.creatorProfile.loadMore": "さらに読み込む",
"marketplace.creatorProfile.loadMoreFailed": "作品を追加で読み込めませんでした。",
"marketplace.creatorProfile.onTheWeb": "ウェブサイト",
"marketplace.creatorProfile.organization": "組織",
"marketplace.creatorProfile.searchPlaceholder": "プラグインとテンプレートを検索",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "작품",
"marketplace.creatorProfile.empty": "아직 작품이 없습니다.",
"marketplace.creatorProfile.home": "Marketplace 홈",
"marketplace.creatorProfile.loadMore": "Load more",
"marketplace.creatorProfile.loadMoreFailed": "Couldn't load more creations.",
"marketplace.creatorProfile.onTheWeb": "웹에서",
"marketplace.creatorProfile.organization": "조직",
"marketplace.creatorProfile.searchPlaceholder": "플러그인 및 템플릿 검색",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "ຜົນງານ",
"marketplace.creatorProfile.empty": "ຍັງບໍ່ມີຜົນງານ.",
"marketplace.creatorProfile.home": "ໜ້າຫຼັກ Marketplace",
"marketplace.creatorProfile.loadMore": "Load more",
"marketplace.creatorProfile.loadMoreFailed": "Couldn't load more creations.",
"marketplace.creatorProfile.onTheWeb": "ເທິງເວັບ",
"marketplace.creatorProfile.organization": "ອົງກອນ",
"marketplace.creatorProfile.searchPlaceholder": "ຄົ້ນຫາປລັກອິນ ແລະ ແມ່ແບບ",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "Creaties",
"marketplace.creatorProfile.empty": "Nog geen creaties.",
"marketplace.creatorProfile.home": "Marketplace-startpagina",
"marketplace.creatorProfile.loadMore": "Load more",
"marketplace.creatorProfile.loadMoreFailed": "Couldn't load more creations.",
"marketplace.creatorProfile.onTheWeb": "Op het web",
"marketplace.creatorProfile.organization": "Organisatie",
"marketplace.creatorProfile.searchPlaceholder": "Zoek plugins en sjablonen",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "Twórczość",
"marketplace.creatorProfile.empty": "Brak prac.",
"marketplace.creatorProfile.home": "Strona główna Marketplace",
"marketplace.creatorProfile.loadMore": "Load more",
"marketplace.creatorProfile.loadMoreFailed": "Couldn't load more creations.",
"marketplace.creatorProfile.onTheWeb": "W sieci",
"marketplace.creatorProfile.organization": "Organizacja",
"marketplace.creatorProfile.searchPlaceholder": "Szukaj wtyczek i szablonów",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "Criações",
"marketplace.creatorProfile.empty": "Nenhuma criação ainda.",
"marketplace.creatorProfile.home": "Página inicial do Marketplace",
"marketplace.creatorProfile.loadMore": "Load more",
"marketplace.creatorProfile.loadMoreFailed": "Couldn't load more creations.",
"marketplace.creatorProfile.onTheWeb": "Na web",
"marketplace.creatorProfile.organization": "Organização",
"marketplace.creatorProfile.searchPlaceholder": "Pesquisar plugins e modelos",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "Creații",
"marketplace.creatorProfile.empty": "Nicio creație încă.",
"marketplace.creatorProfile.home": "Pagina principală Marketplace",
"marketplace.creatorProfile.loadMore": "Load more",
"marketplace.creatorProfile.loadMoreFailed": "Couldn't load more creations.",
"marketplace.creatorProfile.onTheWeb": "Pe web",
"marketplace.creatorProfile.organization": "Organizație",
"marketplace.creatorProfile.searchPlaceholder": "Caută pluginuri și șabloane",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "Работы",
"marketplace.creatorProfile.empty": "Пока нет работ.",
"marketplace.creatorProfile.home": "Главная Marketplace",
"marketplace.creatorProfile.loadMore": "Load more",
"marketplace.creatorProfile.loadMoreFailed": "Couldn't load more creations.",
"marketplace.creatorProfile.onTheWeb": "В интернете",
"marketplace.creatorProfile.organization": "Организация",
"marketplace.creatorProfile.searchPlaceholder": "Поиск плагинов и шаблонов",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "Stvaritve",
"marketplace.creatorProfile.empty": "Še ni stvaritev.",
"marketplace.creatorProfile.home": "Domov Marketplace",
"marketplace.creatorProfile.loadMore": "Load more",
"marketplace.creatorProfile.loadMoreFailed": "Couldn't load more creations.",
"marketplace.creatorProfile.onTheWeb": "Na spletu",
"marketplace.creatorProfile.organization": "Organizacija",
"marketplace.creatorProfile.searchPlaceholder": "Iskanje vtičnikov in predlog",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "ผลงาน",
"marketplace.creatorProfile.empty": "ยังไม่มีผลงาน",
"marketplace.creatorProfile.home": "หน้าแรก Marketplace",
"marketplace.creatorProfile.loadMore": "Load more",
"marketplace.creatorProfile.loadMoreFailed": "Couldn't load more creations.",
"marketplace.creatorProfile.onTheWeb": "บนเว็บ",
"marketplace.creatorProfile.organization": "องค์กร",
"marketplace.creatorProfile.searchPlaceholder": "ค้นหาปลั๊กอินและเทมเพลต",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "Çalışmalar",
"marketplace.creatorProfile.empty": "Henüz çalışma yok.",
"marketplace.creatorProfile.home": "Marketplace ana sayfası",
"marketplace.creatorProfile.loadMore": "Load more",
"marketplace.creatorProfile.loadMoreFailed": "Couldn't load more creations.",
"marketplace.creatorProfile.onTheWeb": "Web'de",
"marketplace.creatorProfile.organization": "Organizasyon",
"marketplace.creatorProfile.searchPlaceholder": "Eklenti ve şablon ara",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "Роботи",
"marketplace.creatorProfile.empty": "Поки немає робіт.",
"marketplace.creatorProfile.home": "Головна Marketplace",
"marketplace.creatorProfile.loadMore": "Load more",
"marketplace.creatorProfile.loadMoreFailed": "Couldn't load more creations.",
"marketplace.creatorProfile.onTheWeb": "В інтернеті",
"marketplace.creatorProfile.organization": "Організація",
"marketplace.creatorProfile.searchPlaceholder": "Пошук плагінів і шаблонів",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "Tác phẩm",
"marketplace.creatorProfile.empty": "Chưa có tác phẩm nào.",
"marketplace.creatorProfile.home": "Trang chủ Marketplace",
"marketplace.creatorProfile.loadMore": "Load more",
"marketplace.creatorProfile.loadMoreFailed": "Couldn't load more creations.",
"marketplace.creatorProfile.onTheWeb": "Trên web",
"marketplace.creatorProfile.organization": "Tổ chức",
"marketplace.creatorProfile.searchPlaceholder": "Tìm plugin và mẫu",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "作品",
"marketplace.creatorProfile.empty": "暂无作品。",
"marketplace.creatorProfile.home": "Marketplace 首页",
"marketplace.creatorProfile.loadMore": "加载更多",
"marketplace.creatorProfile.loadMoreFailed": "无法加载更多作品。",
"marketplace.creatorProfile.onTheWeb": "社交主页",
"marketplace.creatorProfile.organization": "组织",
"marketplace.creatorProfile.searchPlaceholder": "搜索插件和模板",

View File

@ -233,6 +233,8 @@
"marketplace.creatorProfile.creations": "作品",
"marketplace.creatorProfile.empty": "尚無作品。",
"marketplace.creatorProfile.home": "Marketplace 首頁",
"marketplace.creatorProfile.loadMore": "載入更多",
"marketplace.creatorProfile.loadMoreFailed": "無法載入更多作品。",
"marketplace.creatorProfile.onTheWeb": "社交主頁",
"marketplace.creatorProfile.organization": "組織",
"marketplace.creatorProfile.searchPlaceholder": "搜尋外掛和模板",