feat: add templates marketplace functionality

This commit is contained in:
yessenia 2026-02-05 16:47:56 +08:00
parent c73a932343
commit 96933e4349
13 changed files with 1090 additions and 15 deletions

View File

@ -11,6 +11,7 @@ import { PLUGIN_TYPE_SEARCH_MAP } from '../constants'
import SortDropdown from '../sort-dropdown'
import { useMarketplaceData } from '../state'
import List from './index'
import TemplateList from './template-list'
type ListWrapperProps = {
showInstallButton?: boolean
@ -31,15 +32,49 @@ const ListWrapper = ({
const [activePluginType, handleActivePluginTypeChange] = useActivePluginType()
const [searchScope, setSearchScope] = useState<SearchScope>('all')
const marketplaceData = useMarketplaceData()
const {
creationType,
isLoading,
} = marketplaceData
// Templates view
if (creationType === 'templates') {
const { templateCollections, templateCollectionTemplatesMap } = marketplaceData
return (
<div
style={{ scrollbarGutter: 'stable' }}
className="relative flex grow flex-col bg-background-default-subtle px-12 py-2"
>
{
isLoading && (
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<Loading />
</div>
)
}
{
!isLoading && (
<TemplateList
templateCollections={templateCollections || []}
templateCollectionTemplatesMap={templateCollectionTemplatesMap || {}}
/>
)
}
</div>
)
}
// Plugins view (default)
const {
plugins,
pluginsTotal,
marketplaceCollections,
marketplaceCollectionPluginsMap,
isLoading,
isFetchingNextPage,
page,
} = useMarketplaceData()
} = marketplaceData
const pluginsCount = pluginsTotal || 0
const searchScopeOptions: Array<{ value: SearchScope, text: string, count: number }> = searchScopeOptionKeys.map(option => ({
value: option.value,

View File

@ -0,0 +1,92 @@
'use client'
import type { Template } from '../types'
import { useLocale } from '#i18n'
import Image from 'next/image'
import * as React from 'react'
import { getLanguage } from '@/i18n-config/language'
import { cn } from '@/utils/classnames'
type TemplateCardProps = {
template: Template
className?: string
}
const TemplateCardComponent = ({
template,
className,
}: TemplateCardProps) => {
const locale = useLocale()
const { name, description, icon, tags, author } = template
const descriptionText = description[getLanguage(locale)] || description.en_US || ''
return (
<div className={cn(
'hover-bg-components-panel-on-panel-item-bg relative cursor-pointer overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-4 pb-3 shadow-xs',
className,
)}
>
{/* Header */}
<div className="flex">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-background-default-lighter">
{icon
? (
<Image
src={icon}
alt={name}
width={24}
height={24}
className="h-6 w-6 object-contain"
/>
)
: (
<span className="text-lg">📄</span>
)}
</div>
<div className="ml-3 w-0 grow">
<div className="flex h-5 items-center">
<span className="system-md-semibold truncate text-text-secondary">{name}</span>
</div>
<div className="system-2xs-medium-uppercase mt-0.5 truncate text-text-tertiary">
by
{' '}
{author}
</div>
</div>
</div>
{/* Description */}
<div
className="system-xs-regular mt-3 line-clamp-2 text-text-tertiary"
title={descriptionText}
>
{descriptionText}
</div>
{/* Tags */}
{tags && tags.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1">
{tags.slice(0, 3).map(tag => (
<span
key={tag}
className="system-2xs-medium-uppercase bg-components-badge-bg-gray rounded-md px-1.5 py-0.5 text-text-tertiary"
>
{tag}
</span>
))}
{tags.length > 3 && (
<span className="system-2xs-medium-uppercase text-text-quaternary">
+
{tags.length - 3}
</span>
)}
</div>
)}
</div>
)
}
const TemplateCard = React.memo(TemplateCardComponent)
export default TemplateCard

View File

@ -0,0 +1,129 @@
'use client'
import type { Template, TemplateCollection } from '../types'
import { useLocale, useTranslation } from '#i18n'
import { RiArrowRightSLine } from '@remixicon/react'
import { getLanguage } from '@/i18n-config/language'
import Empty from '../empty'
import Carousel from './carousel'
import TemplateCard from './template-card'
type TemplateListProps = {
templateCollections: TemplateCollection[]
templateCollectionTemplatesMap: Record<string, Template[]>
cardContainerClassName?: string
}
const FEATURED_COLLECTION_NAME = 'featured'
const GRID_DISPLAY_LIMIT = 8
const TemplateList = ({
templateCollections,
templateCollectionTemplatesMap,
cardContainerClassName,
}: TemplateListProps) => {
const { t } = useTranslation()
const locale = useLocale()
const renderTemplateCard = (template: Template) => {
return (
<TemplateCard
key={template.template_id}
template={template}
/>
)
}
const renderFeaturedCarousel = (collection: TemplateCollection, templates: Template[]) => {
// Featured collection: 2-row carousel with auto-play
const rows: Template[][] = []
for (let i = 0; i < templates.length; i += 2) {
rows.push(templates.slice(i, i + 2))
}
return (
<Carousel
className={cardContainerClassName}
showNavigation={templates.length > 8}
showPagination={templates.length > 8}
autoPlay={templates.length > 8}
autoPlayInterval={5000}
>
{rows.map(columnTemplates => (
<div
key={`column-${columnTemplates[0]?.template_id}`}
className="flex w-[calc((100%-0px)/1)] shrink-0 flex-col gap-3 sm:w-[calc((100%-12px)/2)] lg:w-[calc((100%-24px)/3)] xl:w-[calc((100%-36px)/4)]"
style={{ scrollSnapAlign: 'start' }}
>
{columnTemplates.map(template => (
<div key={template.template_id}>
{renderTemplateCard(template)}
</div>
))}
</div>
))}
</Carousel>
)
}
const renderGridCollection = (collection: TemplateCollection, templates: Template[]) => {
const displayTemplates = templates.slice(0, GRID_DISPLAY_LIMIT)
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{displayTemplates.map(template => (
<div key={template.template_id}>
{renderTemplateCard(template)}
</div>
))}
</div>
)
}
const collectionsWithTemplates = templateCollections.filter((collection) => {
return templateCollectionTemplatesMap[collection.name]?.length
})
if (collectionsWithTemplates.length === 0) {
return <Empty />
}
return (
<>
{
collectionsWithTemplates.map((collection) => {
const templates = templateCollectionTemplatesMap[collection.name]
const isFeaturedCollection = collection.name === FEATURED_COLLECTION_NAME
const showViewMore = collection.searchable && (isFeaturedCollection || templates.length > GRID_DISPLAY_LIMIT)
return (
<div
key={collection.name}
className="py-3"
>
<div className="mb-2 flex items-end justify-between">
<div>
<div className="title-xl-semi-bold text-text-primary">{collection.label[getLanguage(locale)]}</div>
<div className="system-xs-regular text-text-tertiary">{collection.description[getLanguage(locale)]}</div>
</div>
{showViewMore && (
<div
className="system-xs-medium flex cursor-pointer items-center text-text-accent"
>
{t('marketplace.viewMore', { ns: 'plugin' })}
<RiArrowRightSLine className="h-4 w-4" />
</div>
)}
</div>
{isFeaturedCollection
? renderFeaturedCarousel(collection, templates)
: renderGridCollection(collection, templates)}
</div>
)
})
}
</>
)
}
export default TemplateList

View File

@ -1,8 +1,8 @@
import type { PluginsSearchParams } from './types'
import type { PluginsSearchParams, TemplateSearchParams } from './types'
import type { MarketPlaceInputs } from '@/contract/router'
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
import { marketplaceQuery } from '@/service/client'
import { getMarketplaceCollectionsAndPlugins, getMarketplacePlugins } from './utils'
import { getMarketplaceCollectionsAndPlugins, getMarketplacePlugins, getMarketplaceTemplateCollectionsAndTemplates, getMarketplaceTemplates } from './utils'
export function useMarketplaceCollectionsAndPlugins(
collectionsParams: MarketPlaceInputs['collections']['query'],
@ -13,6 +13,15 @@ export function useMarketplaceCollectionsAndPlugins(
})
}
export function useMarketplaceTemplateCollectionsAndTemplates(
query?: { page?: number, page_size?: number, condition?: string },
) {
return useQuery({
queryKey: marketplaceQuery.templateCollections.list.queryKey({ input: { query } }),
queryFn: ({ signal }) => getMarketplaceTemplateCollectionsAndTemplates(query, { signal }),
})
}
export function useMarketplacePlugins(
queryParams: PluginsSearchParams | undefined,
) {
@ -33,3 +42,23 @@ export function useMarketplacePlugins(
enabled: queryParams !== undefined,
})
}
export function useMarketplaceTemplates(
queryParams: TemplateSearchParams | undefined,
) {
return useInfiniteQuery({
queryKey: marketplaceQuery.templates.searchAdvanced.queryKey({
input: {
body: queryParams!,
},
}),
queryFn: ({ pageParam = 1, signal }) => getMarketplaceTemplates(queryParams, pageParam, signal),
getNextPageParam: (lastPage) => {
const nextPage = lastPage.page + 1
const loaded = lastPage.page * lastPage.page_size
return loaded < (lastPage.total || 0) ? nextPage : undefined
},
initialPageParam: 1,
enabled: queryParams !== undefined,
})
}

View File

@ -1,13 +1,18 @@
import type { PluginsSearchParams } from './types'
import type { PluginsSearchParams, TemplateSearchParams } from './types'
import { useDebounce } from 'ahooks'
import { useSearchParams } from 'next/navigation'
import { useCallback, useMemo } from 'react'
import { useActivePluginType, useFilterPluginTags, useMarketplaceSearchMode, useMarketplaceSortValue, useSearchPluginText } from './atoms'
import { PLUGIN_TYPE_SEARCH_MAP } from './constants'
import { useMarketplaceContainerScroll } from './hooks'
import { useMarketplaceCollectionsAndPlugins, useMarketplacePlugins } from './query'
import { useMarketplaceCollectionsAndPlugins, useMarketplacePlugins, useMarketplaceTemplateCollectionsAndTemplates, useMarketplaceTemplates } from './query'
import { getCollectionsParams, getMarketplaceListFilterType } from './utils'
export function useMarketplaceData() {
/**
* Hook for plugins marketplace data
* Only fetches plugins-related data
*/
export function usePluginsMarketplaceData() {
const [searchPluginTextOriginal] = useSearchPluginText()
const searchPluginText = useDebounce(searchPluginTextOriginal, { wait: 500 })
const [filterPluginTags] = useFilterPluginTags()
@ -53,3 +58,133 @@ export function useMarketplaceData() {
isFetchingNextPage,
}
}
/**
* Hook for templates marketplace data
* Only fetches templates-related data
*/
export function useTemplatesMarketplaceData() {
// Reuse existing atoms for search and sort
const [searchTextOriginal] = useSearchPluginText()
const searchText = useDebounce(searchTextOriginal, { wait: 500 })
const [activeCategory] = useActivePluginType()
// Template collections query (for non-search mode)
const templateCollectionsQuery = useMarketplaceTemplateCollectionsAndTemplates()
// Sort value
const sort = useMarketplaceSortValue()
// Search mode: when there's search text or non-default category
const isSearchMode = !!searchText || (activeCategory !== PLUGIN_TYPE_SEARCH_MAP.all)
// Build query params for search mode
const queryParams = useMemo((): TemplateSearchParams | undefined => {
if (!isSearchMode)
return undefined
return {
query: searchText,
categories: activeCategory === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : [activeCategory],
sort_by: sort.sortBy,
sort_order: sort.sortOrder,
}
}, [isSearchMode, searchText, activeCategory, sort])
// Templates search query (for search mode)
const templatesQuery = useMarketplaceTemplates(queryParams)
const { hasNextPage, fetchNextPage, isFetching, isFetchingNextPage } = templatesQuery
// Pagination handler
const handlePageChange = useCallback(() => {
if (hasNextPage && !isFetching)
fetchNextPage()
}, [fetchNextPage, hasNextPage, isFetching])
// Scroll pagination
useMarketplaceContainerScroll(handlePageChange)
// Compute flat templates list from collection map (for non-search mode)
const { collectionTemplates, collectionTemplatesTotal } = useMemo(() => {
const templateCollectionTemplatesMap = templateCollectionsQuery.data?.templateCollectionTemplatesMap
if (!templateCollectionTemplatesMap) {
return { collectionTemplates: undefined, collectionTemplatesTotal: 0 }
}
const allTemplates = Object.values(templateCollectionTemplatesMap).flat()
// Deduplicate templates by template_id
const uniqueTemplates = allTemplates.filter(
(template, index, self) => index === self.findIndex(t => t.template_id === template.template_id),
)
return {
collectionTemplates: uniqueTemplates,
collectionTemplatesTotal: uniqueTemplates.length,
}
}, [templateCollectionsQuery.data?.templateCollectionTemplatesMap])
// Return search results when in search mode, otherwise return collection data
if (isSearchMode) {
return {
isSearchMode,
templateCollections: undefined,
templateCollectionTemplatesMap: undefined,
templates: templatesQuery.data?.pages.flatMap(page => page.templates),
templatesTotal: templatesQuery.data?.pages[0]?.total,
page: templatesQuery.data?.pages.length || 1,
isLoading: templatesQuery.isLoading,
isFetchingNextPage,
}
}
return {
isSearchMode,
templateCollections: templateCollectionsQuery.data?.templateCollections,
templateCollectionTemplatesMap: templateCollectionsQuery.data?.templateCollectionTemplatesMap,
templates: collectionTemplates,
templatesTotal: collectionTemplatesTotal,
page: 1,
isLoading: templateCollectionsQuery.isLoading,
isFetchingNextPage: false,
}
}
/**
* Main hook that routes to appropriate data based on creationType
* Returns either plugins or templates data based on URL parameter
*/
export function useMarketplaceData() {
const searchParams = useSearchParams()
const creationType = (searchParams.get('creationType') || 'plugins') as 'plugins' | 'templates'
const pluginsData = usePluginsMarketplaceData()
const templatesData = useTemplatesMarketplaceData()
if (creationType === 'templates') {
return {
creationType,
isSearchMode: templatesData.isSearchMode,
// Templates-specific fields
templateCollections: templatesData.templateCollections,
templateCollectionTemplatesMap: templatesData.templateCollectionTemplatesMap,
templates: templatesData.templates,
templatesTotal: templatesData.templatesTotal,
page: templatesData.page,
isLoading: templatesData.isLoading,
isFetchingNextPage: templatesData.isFetchingNextPage,
}
}
// Default: plugins
return {
creationType,
isSearchMode: false, // plugins uses useMarketplaceSearchMode separately
// Plugins-specific fields
marketplaceCollections: pluginsData.marketplaceCollections,
marketplaceCollectionPluginsMap: pluginsData.marketplaceCollectionPluginsMap,
plugins: pluginsData.plugins,
pluginsTotal: pluginsData.pluginsTotal,
page: pluginsData.page,
isLoading: pluginsData.isLoading,
isFetchingNextPage: pluginsData.isFetchingNextPage,
}
}

View File

@ -56,4 +56,131 @@ export type SearchParams = {
q?: string
tags?: string
category?: string
creationType?: string
}
export type TemplateCollection = {
id: string
name: string
label: Record<string, string>
description: Record<string, string>
conditions: string[]
searchable: boolean
search_params?: SearchParamsFromCollection
created_at?: string
updated_at?: string
}
export type Template = {
template_id: string
name: string
description: Record<string, string>
icon: string
tags: string[]
author: string
created_at: string
updated_at: string
}
export type CreateTemplateCollectionRequest = {
name: string
description: Record<string, string>
label: Record<string, string>
conditions: string[]
searchable: boolean
search_params: SearchParamsFromCollection
}
export type GetCollectionTemplatesRequest = {
categories?: string[]
exclude?: string[]
limit?: number
}
export type AddTemplateToCollectionRequest = {
template_id: string
}
export type BatchAddTemplatesToCollectionRequest = {
template_id: string
}[]
// Creator types
export type Creator = {
email: string
name: string
display_name: string
unique_handle: string
display_email: string
description: string
avatar: string
social_links: string[]
status: 'active' | 'inactive'
public?: boolean
plugin_count?: number
template_count?: number
created_at: string
updated_at: string
}
export type CreatorSearchParams = {
query?: string
page?: number
page_size?: number
categories?: string[]
sort_by?: string
sort_order?: string
}
export type CreatorSearchResponse = {
creators: Creator[]
total: number
}
export type SyncCreatorProfileRequest = {
email: string
name?: string
display_name?: string
unique_handle: string
display_email?: string
description?: string
avatar?: string
social_links?: string[]
status?: 'active' | 'inactive'
}
// Template Detail (full template info from API)
export type TemplateDetail = {
id: string
publisher_type: 'individual' | 'organization'
publisher_unique_handle: string
creator_email: string
template_name: string
icon: string
icon_background: string
icon_file_key: string
dsl_file_key: string
categories: string[]
overview: string
readme: string
partner_link: string
status: 'published' | 'draft' | 'pending' | 'rejected'
review_comment: string
created_at: string
updated_at: string
}
export type TemplatesListResponse = {
templates: TemplateDetail[]
total: number
}
export type TemplateSearchParams = {
query?: string
page?: number
page_size?: number
categories?: string[]
sort_by?: string
sort_order?: string
languages?: string[]
}

View File

@ -3,6 +3,10 @@ import type {
CollectionsAndPluginsSearchParams,
MarketplaceCollection,
PluginsSearchParams,
Template,
TemplateCollection,
TemplateDetail,
TemplateSearchParams,
} from '@/app/components/plugins/marketplace/types'
import type { Plugin } from '@/app/components/plugins/types'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
@ -112,6 +116,49 @@ export const getMarketplaceCollectionsAndPlugins = async (
}
}
export const getMarketplaceTemplateCollectionsAndTemplates = async (
query?: { page?: number, page_size?: number, condition?: string },
options?: MarketplaceFetchOptions,
) => {
let templateCollections: TemplateCollection[] = []
let templateCollectionTemplatesMap: Record<string, Template[]> = {}
try {
const res = await marketplaceClient.templateCollections.list({
query: {
...query,
page: 1,
page_size: 100,
},
}, {
signal: options?.signal,
})
templateCollections = res.data || []
await Promise.all(templateCollections.map(async (collection) => {
try {
const templatesRes = await marketplaceClient.templateCollections.getTemplates({
params: { collectionName: collection.name },
body: { limit: 20 },
}, { signal: options?.signal })
templateCollectionTemplatesMap[collection.name] = (templatesRes.data || []) as Template[]
}
catch {
templateCollectionTemplatesMap[collection.name] = []
}
}))
}
catch {
templateCollections = []
templateCollectionTemplatesMap = {}
}
return {
templateCollections,
templateCollectionTemplatesMap,
}
}
export const getMarketplacePlugins = async (
queryParams: PluginsSearchParams | undefined,
pageParam: number,
@ -203,3 +250,61 @@ export function getCollectionsParams(category: ActivePluginType): CollectionsAnd
type: getMarketplaceListFilterType(category),
}
}
export const getMarketplaceTemplates = async (
queryParams: TemplateSearchParams | undefined,
pageParam: number,
signal?: AbortSignal,
): Promise<{
templates: TemplateDetail[]
total: number
page: number
page_size: number
}> => {
if (!queryParams) {
return {
templates: [] as TemplateDetail[],
total: 0,
page: 1,
page_size: 40,
}
}
const {
query,
sort_by,
sort_order,
categories,
languages,
page_size = 40,
} = queryParams
try {
const res = await marketplaceClient.templates.searchAdvanced({
body: {
page: pageParam,
page_size,
query,
sort_by,
sort_order,
categories,
languages,
},
}, { signal })
return {
templates: res.data?.templates || [],
total: res.data?.total || 0,
page: pageParam,
page_size,
}
}
catch {
return {
templates: [],
total: 0,
page: pageParam,
page_size,
}
}
}

View File

@ -30,8 +30,8 @@ import {
} from './context'
import DebugInfo from './debug-info'
import InstallPluginDropdown from './install-plugin-dropdown'
import { SubmitRequestDropdown } from './nav-operations'
import PluginTasks from './plugin-tasks'
import SubmitRequestDropdown from './submit-request-dropdown'
import useReferenceSetting from './use-reference-setting'
import { useUploader } from './use-uploader'

View File

@ -1,9 +1,11 @@
'use client'
import { RiBookOpenLine, RiGithubLine } from '@remixicon/react'
import { RiBookOpenLine, RiGithubLine, RiLayoutGridLine, RiPuzzle2Line } from '@remixicon/react'
import Link from 'next/link'
import { useSearchParams } from 'next/navigation'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Badge from '@/app/components/base/badge'
import Button, { buttonVariants } from '@/app/components/base/button'
import {
PortalToFollowElem,
PortalToFollowElemContent,
@ -31,7 +33,7 @@ const DropdownItem = ({ href, icon, text, onClick }: DropdownItemProps) => (
</Link>
)
const SubmitRequestDropdown = () => {
export const SubmitRequestDropdown = () => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const docLink = useDocLink()
@ -54,7 +56,6 @@ const SubmitRequestDropdown = () => {
<span className="system-sm-medium">
{t('requestSubmitPlugin', { ns: 'plugin' })}
</span>
{/* <RiArrowDownSLine className={cn("h-4 w-4 transition-transform", open && "rotate-180")} /> */}
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[1000]">
@ -77,4 +78,42 @@ const SubmitRequestDropdown = () => {
)
}
export default SubmitRequestDropdown
export const CreationTypeTabs = () => {
const { t } = useTranslation()
const searchParams = useSearchParams()
const creationType = searchParams.get('creationType') || 'plugins'
return (
<div className="flex items-center gap-1">
<Link
href="/?creationType=plugins"
className={cn(
buttonVariants({ variant: 'ghost' }),
'flex items-center gap-1 px-3 py-2 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
creationType === 'plugins' && 'bg-state-base-hover text-text-secondary',
)}
>
<RiPuzzle2Line className="h-4 w-4 shrink-0" />
<span className="system-sm-medium">
{t('plugins', { ns: 'plugin' })}
</span>
</Link>
<Link
href="/?creationType=templates"
className={cn(
buttonVariants({ variant: 'ghost' }),
'flex items-center gap-1 px-3 py-2 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
creationType === 'templates' && 'bg-state-base-hover text-text-secondary',
)}
>
<RiLayoutGridLine className="h-4 w-4 shrink-0" />
<span className="system-sm-medium">
{t('templates', { ns: 'plugin' })}
</span>
<Badge className="ml-1 h-4 rounded-[4px] border-none bg-saas-dify-blue-accessible px-1 text-[10px] font-bold leading-[14px] text-text-primary-on-surface">
NEW
</Badge>
</Link>
</div>
)
}

View File

@ -1,4 +1,21 @@
import type { CollectionsAndPluginsSearchParams, MarketplaceCollection, PluginsSearchParams } from '@/app/components/plugins/marketplace/types'
import type {
AddTemplateToCollectionRequest,
BatchAddTemplatesToCollectionRequest,
CollectionsAndPluginsSearchParams,
CreateTemplateCollectionRequest,
Creator,
CreatorSearchParams,
CreatorSearchResponse,
GetCollectionTemplatesRequest,
MarketplaceCollection,
PluginsSearchParams,
SyncCreatorProfileRequest,
Template,
TemplateCollection,
TemplateDetail,
TemplateSearchParams,
TemplatesListResponse,
} from '@/app/components/plugins/marketplace/types'
import type { Plugin, PluginsFromMarketplaceResponse } from '@/app/components/plugins/types'
import { type } from '@orpc/contract'
import { base } from './base'
@ -54,3 +71,318 @@ export const searchAdvancedContract = base
body: Omit<PluginsSearchParams, 'type'>
}>())
.output(type<{ data: PluginsFromMarketplaceResponse }>())
export const templateCollectionsContract = base
.route({
path: '/template-collections',
method: 'GET',
})
.input(
type<{
query?: {
page?: number
page_size?: number
condition?: string
}
}>(),
)
.output(
type<{
data?: TemplateCollection[]
has_more?: boolean
limit?: number
page?: number
total?: number
}>(),
)
export const createTemplateCollectionContract = base
.route({
path: '/template-collections',
method: 'POST',
})
.input(
type<{
body: CreateTemplateCollectionRequest
}>(),
)
.output(type<TemplateCollection>())
export const getTemplateCollectionContract = base
.route({
path: '/template-collections/{collectionName}',
method: 'GET',
})
.input(
type<{
params: {
collectionName: string
}
}>(),
)
.output(type<TemplateCollection>())
export const deleteTemplateCollectionContract = base
.route({
path: '/template-collections/{collectionName}',
method: 'DELETE',
})
.input(
type<{
params: {
collectionName: string
}
}>(),
)
.output(type<void>())
export const getCollectionTemplatesContract = base
.route({
path: '/template-collections/{collectionName}/templates',
method: 'POST',
})
.input(
type<{
params: {
collectionName: string
}
body?: GetCollectionTemplatesRequest
}>(),
)
.output(
type<{
data?: Template[]
}>(),
)
export const addTemplateToCollectionContract = base
.route({
path: '/template-collections/{collectionName}/templates',
method: 'PUT',
})
.input(
type<{
params: {
collectionName: string
}
body: AddTemplateToCollectionRequest
}>(),
)
.output(type<void>())
export const batchAddTemplatesToCollectionContract = base
.route({
path: '/template-collections/{collectionName}/templates/batch-add',
method: 'POST',
})
.input(
type<{
params: {
collectionName: string
}
body: BatchAddTemplatesToCollectionRequest
}>(),
)
.output(type<void>())
export const clearCollectionTemplatesContract = base
.route({
path: '/template-collections/{collectionName}/templates/clear',
method: 'PUT',
})
.input(
type<{
params: {
collectionName: string
}
}>(),
)
.output(type<void>())
// Creators contracts
export const getCreatorByHandleContract = base
.route({
path: '/creators/{uniqueHandle}',
method: 'GET',
})
.input(
type<{
params: {
uniqueHandle: string
}
}>(),
)
.output(
type<{
data?: {
creator?: Creator
}
}>(),
)
export const getCreatorAvatarContract = base
.route({
path: '/creators/{uniqueHandle}/avatar',
method: 'GET',
})
.input(
type<{
params: {
uniqueHandle: string
}
}>(),
)
.output(type<Blob>())
export const syncCreatorProfileContract = base
.route({
path: '/creators/sync/profile',
method: 'POST',
})
.input(
type<{
body: SyncCreatorProfileRequest
}>(),
)
.output(
type<{
data?: {
creator?: Creator
}
}>(),
)
export const syncCreatorAvatarContract = base
.route({
path: '/creators/sync/avatar',
method: 'POST',
})
.input(
type<{
body: FormData
}>(),
)
.output(type<void>())
export const searchCreatorsAdvancedContract = base
.route({
path: '/creators/search/advanced',
method: 'POST',
})
.input(
type<{
body: CreatorSearchParams
}>(),
)
.output(
type<{
data?: CreatorSearchResponse
}>(),
)
// Templates public endpoints
export const getTemplatesListContract = base
.route({
path: '/templates',
method: 'GET',
})
.input(
type<{
query?: {
page?: number
page_size?: number
categories?: string
}
}>(),
)
.output(
type<{
data?: TemplatesListResponse
}>(),
)
export const getTemplateByIdContract = base
.route({
path: '/templates/{templateId}',
method: 'GET',
})
.input(
type<{
params: {
templateId: string
}
}>(),
)
.output(
type<{
data?: TemplateDetail
}>(),
)
export const getTemplateDslFileContract = base
.route({
path: '/templates/{templateId}/file',
method: 'GET',
})
.input(
type<{
params: {
templateId: string
}
}>(),
)
.output(type<Blob>())
export const searchTemplatesBasicContract = base
.route({
path: '/templates/search/basic',
method: 'POST',
})
.input(
type<{
body: TemplateSearchParams
}>(),
)
.output(
type<{
data?: TemplatesListResponse
}>(),
)
export const searchTemplatesAdvancedContract = base
.route({
path: '/templates/search/advanced',
method: 'POST',
})
.input(
type<{
body: TemplateSearchParams
}>(),
)
.output(
type<{
data?: TemplatesListResponse
}>(),
)
export const getPublisherTemplatesContract = base
.route({
path: '/templates/publisher/{uniqueHandle}',
method: 'GET',
})
.input(
type<{
params: {
uniqueHandle: string
}
query?: {
page?: number
page_size?: number
}
}>(),
)
.output(
type<{
data?: TemplatesListResponse
}>(),
)

View File

@ -2,12 +2,60 @@ import type { InferContractRouterInputs } from '@orpc/contract'
import { bindPartnerStackContract, invoicesContract } from './console/billing'
import { systemFeaturesContract } from './console/system'
import { trialAppDatasetsContract, trialAppInfoContract, trialAppParametersContract, trialAppWorkflowsContract } from './console/try-app'
import { collectionPluginsContract, collectionsContract, searchAdvancedContract } from './marketplace'
import {
addTemplateToCollectionContract,
batchAddTemplatesToCollectionContract,
clearCollectionTemplatesContract,
collectionPluginsContract,
collectionsContract,
createTemplateCollectionContract,
deleteTemplateCollectionContract,
getCollectionTemplatesContract,
getCreatorAvatarContract,
getCreatorByHandleContract,
getPublisherTemplatesContract,
getTemplateByIdContract,
getTemplateCollectionContract,
getTemplateDslFileContract,
getTemplatesListContract,
searchAdvancedContract,
searchCreatorsAdvancedContract,
searchTemplatesAdvancedContract,
searchTemplatesBasicContract,
syncCreatorAvatarContract,
syncCreatorProfileContract,
templateCollectionsContract,
} from './marketplace'
export const marketplaceRouterContract = {
collections: collectionsContract,
collectionPlugins: collectionPluginsContract,
searchAdvanced: searchAdvancedContract,
templateCollections: {
list: templateCollectionsContract,
create: createTemplateCollectionContract,
get: getTemplateCollectionContract,
delete: deleteTemplateCollectionContract,
getTemplates: getCollectionTemplatesContract,
addTemplate: addTemplateToCollectionContract,
batchAddTemplates: batchAddTemplatesToCollectionContract,
clearTemplates: clearCollectionTemplatesContract,
},
creators: {
getByHandle: getCreatorByHandleContract,
getAvatar: getCreatorAvatarContract,
syncProfile: syncCreatorProfileContract,
syncAvatar: syncCreatorAvatarContract,
searchAdvanced: searchCreatorsAdvancedContract,
},
templates: {
list: getTemplatesListContract,
getById: getTemplateByIdContract,
getDslFile: getTemplateDslFileContract,
searchBasic: searchTemplatesBasicContract,
searchAdvanced: searchTemplatesAdvancedContract,
getPublisherTemplates: getPublisherTemplatesContract,
},
}
export type MarketPlaceInputs = InferContractRouterInputs<typeof marketplaceRouterContract>

View File

@ -228,6 +228,8 @@
"pluginInfoModal.release": "Release",
"pluginInfoModal.repository": "Repository",
"pluginInfoModal.title": "Plugin info",
"plugins": "Plugins",
"templates": "Templates",
"privilege.admins": "Admins",
"privilege.everyone": "Everyone",
"privilege.noone": "No one",

View File

@ -228,6 +228,8 @@
"pluginInfoModal.release": "发布版本",
"pluginInfoModal.repository": "仓库",
"pluginInfoModal.title": "插件信息",
"plugins": "插件",
"templates": "模板",
"privilege.admins": "管理员",
"privilege.everyone": "所有人",
"privilege.noone": "无人",