mirror of
https://github.com/langgenius/dify.git
synced 2026-09-05 00:31:19 +08:00
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:
parent
17135522af
commit
ea6c67b231
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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]),
|
||||
),
|
||||
})
|
||||
@ -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>
|
||||
|
||||
@ -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": "ابحث عن الإضافات والقوالب",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "جستجوی افزونه و قالب",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "प्लगिन और टेम्पलेट खोजें",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "プラグインとテンプレートを検索",
|
||||
|
||||
@ -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": "플러그인 및 템플릿 검색",
|
||||
|
||||
@ -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": "ຄົ້ນຫາປລັກອິນ ແລະ ແມ່ແບບ",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "Поиск плагинов и шаблонов",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "ค้นหาปลั๊กอินและเทมเพลต",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "Пошук плагінів і шаблонів",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "搜索插件和模板",
|
||||
|
||||
@ -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": "搜尋外掛和模板",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user