mirror of https://github.com/langgenius/dify.git
feat: enhance templates marketplace with new sorting options and improved template data structure
This commit is contained in:
parent
5c6da34539
commit
6ef87550e6
|
|
@ -3,7 +3,7 @@ import type { PluginsSort, SearchParamsFromCollection } from './types'
|
|||
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import { useCallback } from 'react'
|
||||
import { DEFAULT_SORT, getValidatedPluginCategory, getValidatedTemplateCategory, PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants'
|
||||
import { CATEGORY_ALL, DEFAULT_SORT, getValidatedPluginCategory, getValidatedTemplateCategory, PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants'
|
||||
import { CREATION_TYPE, marketplaceSearchParamsParsers } from './search-params'
|
||||
|
||||
const marketplaceSortAtom = atom<PluginsSort>(DEFAULT_SORT)
|
||||
|
|
@ -53,12 +53,14 @@ export function useMarketplaceSearchMode() {
|
|||
const [searchTab] = useSearchTab()
|
||||
const [filterPluginTags] = useFilterPluginTags()
|
||||
const [activePluginCategory] = useActivePluginCategory()
|
||||
const [activeTemplateCategory] = useActiveTemplateCategory()
|
||||
const isPluginsView = creationType === CREATION_TYPE.plugins
|
||||
|
||||
const searchMode = useAtomValue(searchModeAtom)
|
||||
const isSearchMode = searchTab || searchText
|
||||
|| (isPluginsView && filterPluginTags.length > 0)
|
||||
|| (searchMode ?? (isPluginsView && !PLUGIN_CATEGORY_WITH_COLLECTIONS.has(activePluginCategory)))
|
||||
|| (!isPluginsView && activeTemplateCategory !== CATEGORY_ALL)
|
||||
return isSearchMode
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,11 @@ export const DEFAULT_SORT = {
|
|||
sortOrder: 'DESC',
|
||||
}
|
||||
|
||||
export const DEFAULT_TEMPLATE_SORT = {
|
||||
sortBy: 'usage_count',
|
||||
sortOrder: 'DESC',
|
||||
}
|
||||
|
||||
export const SCROLL_BOTTOM_THRESHOLD = 100
|
||||
|
||||
export const CATEGORY_ALL = 'all'
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
|||
// ================================
|
||||
|
||||
// Note: Import after mocks are set up
|
||||
import { DEFAULT_SORT, PLUGIN_TYPE_SEARCH_MAP, SCROLL_BOTTOM_THRESHOLD } from './constants'
|
||||
import { DEFAULT_SORT, DEFAULT_TEMPLATE_SORT, PLUGIN_TYPE_SEARCH_MAP, SCROLL_BOTTOM_THRESHOLD } from './constants'
|
||||
import {
|
||||
getFormattedPlugin,
|
||||
getPluginCondition,
|
||||
|
|
@ -423,6 +423,23 @@ describe('constants', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('DEFAULT_TEMPLATE_SORT', () => {
|
||||
it('should have correct default sort values for templates', () => {
|
||||
expect(DEFAULT_TEMPLATE_SORT).toEqual({
|
||||
sortBy: 'usage_count',
|
||||
sortOrder: 'DESC',
|
||||
})
|
||||
})
|
||||
|
||||
it('should be immutable at runtime', () => {
|
||||
const originalSortBy = DEFAULT_TEMPLATE_SORT.sortBy
|
||||
const originalSortOrder = DEFAULT_TEMPLATE_SORT.sortOrder
|
||||
|
||||
expect(DEFAULT_TEMPLATE_SORT.sortBy).toBe(originalSortBy)
|
||||
expect(DEFAULT_TEMPLATE_SORT.sortOrder).toBe(originalSortOrder)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SCROLL_BOTTOM_THRESHOLD', () => {
|
||||
it('should be 100 pixels', () => {
|
||||
expect(SCROLL_BOTTOM_THRESHOLD).toBe(100)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
export const GRID_CLASS = 'grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'
|
||||
|
||||
export const GRID_DISPLAY_LIMIT = 8
|
||||
|
||||
export const CAROUSEL_COLUMN_CLASS = '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)]'
|
||||
|
||||
/** Collection name key that triggers carousel display (plugins: partners, templates: featured) */
|
||||
export const CAROUSEL_COLLECTION_NAMES = {
|
||||
partners: 'partners',
|
||||
featured: 'featured',
|
||||
} as const
|
||||
|
||||
export type BaseCollection = {
|
||||
name: string
|
||||
label: Record<string, string>
|
||||
description: Record<string, string>
|
||||
searchable?: boolean
|
||||
search_params?: { query?: string, sort_by?: string, sort_order?: string }
|
||||
}
|
||||
|
|
@ -2,42 +2,24 @@
|
|||
|
||||
import type { SearchTab } from '../search-params'
|
||||
import type { SearchParamsFromCollection } from '../types'
|
||||
import type { BaseCollection } from './collection-constants'
|
||||
import type { Locale } from '@/i18n-config/language'
|
||||
import { useLocale, useTranslation } from '#i18n'
|
||||
import { RiArrowRightSLine } from '@remixicon/react'
|
||||
import { getLanguage } from '@/i18n-config/language'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useMarketplaceMoreClick } from '../atoms'
|
||||
import { getItemKeyByField } from '../utils'
|
||||
import Empty from '../empty'
|
||||
import { getItemKeyByField } from '../utils'
|
||||
import Carousel from './carousel'
|
||||
|
||||
export const GRID_CLASS = 'grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'
|
||||
|
||||
export const GRID_DISPLAY_LIMIT = 8
|
||||
|
||||
export const CAROUSEL_COLUMN_CLASS = '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)]'
|
||||
|
||||
/** Collection name key that triggers carousel display (plugins: partners, templates: featured) */
|
||||
export const CAROUSEL_COLLECTION_NAMES = {
|
||||
partners: 'partners',
|
||||
featured: 'featured',
|
||||
} as const
|
||||
|
||||
export type BaseCollection = {
|
||||
name: string
|
||||
label: Record<string, string>
|
||||
description: Record<string, string>
|
||||
searchable?: boolean
|
||||
search_params?: { query?: string, sort_by?: string, sort_order?: string }
|
||||
}
|
||||
import { CAROUSEL_COLUMN_CLASS, GRID_CLASS, GRID_DISPLAY_LIMIT } from './collection-constants'
|
||||
|
||||
type ViewMoreButtonProps = {
|
||||
searchParams?: SearchParamsFromCollection
|
||||
searchTab?: SearchTab
|
||||
}
|
||||
|
||||
function ViewMoreButton({ searchParams, searchTab }: ViewMoreButtonProps) {
|
||||
export function ViewMoreButton({ searchParams, searchTab }: ViewMoreButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
const onMoreClick = useMarketplaceMoreClick()
|
||||
|
||||
|
|
@ -52,8 +34,6 @@ function ViewMoreButton({ searchParams, searchTab }: ViewMoreButtonProps) {
|
|||
)
|
||||
}
|
||||
|
||||
export { ViewMoreButton }
|
||||
|
||||
type CollectionHeaderProps<TCollection extends BaseCollection> = {
|
||||
collection: TCollection
|
||||
itemsLength: number
|
||||
|
|
@ -62,7 +42,7 @@ type CollectionHeaderProps<TCollection extends BaseCollection> = {
|
|||
viewMore: React.ReactNode
|
||||
}
|
||||
|
||||
function CollectionHeader<TCollection extends BaseCollection>({
|
||||
export function CollectionHeader<TCollection extends BaseCollection>({
|
||||
collection,
|
||||
itemsLength,
|
||||
locale,
|
||||
|
|
@ -87,8 +67,6 @@ function CollectionHeader<TCollection extends BaseCollection>({
|
|||
)
|
||||
}
|
||||
|
||||
export { CarouselCollection, CollectionHeader }
|
||||
|
||||
type CarouselCollectionProps<TItem> = {
|
||||
items: TItem[]
|
||||
getItemKey: (item: TItem) => string
|
||||
|
|
@ -96,7 +74,7 @@ type CarouselCollectionProps<TItem> = {
|
|||
cardContainerClassName?: string
|
||||
}
|
||||
|
||||
function CarouselCollection<TItem>({
|
||||
export function CarouselCollection<TItem>({
|
||||
items,
|
||||
getItemKey,
|
||||
renderCard,
|
||||
|
|
@ -132,7 +110,7 @@ function CarouselCollection<TItem>({
|
|||
type CollectionListProps<TItem, TCollection extends BaseCollection> = {
|
||||
collections: TCollection[]
|
||||
collectionItemsMap: Record<string, TItem[]>
|
||||
/** Field name to use as item key (e.g. 'plugin_id', 'template_id'). */
|
||||
/** Field name to use as item key (e.g. 'plugin_id', 'id'). */
|
||||
itemKeyField: keyof TItem
|
||||
renderCard: (item: TItem) => React.ReactNode
|
||||
/** Collection names that use carousel layout (e.g. ['partners'], ['featured']). */
|
||||
|
|
@ -186,22 +164,22 @@ function CollectionList<TItem, TCollection extends BaseCollection>({
|
|||
/>
|
||||
{isCarouselCollection
|
||||
? (
|
||||
<CarouselCollection
|
||||
items={items}
|
||||
getItemKey={(item) => getItemKeyByField(item, itemKeyField)}
|
||||
renderCard={renderCard}
|
||||
cardContainerClassName={cardContainerClassName}
|
||||
/>
|
||||
)
|
||||
<CarouselCollection
|
||||
items={items}
|
||||
getItemKey={item => getItemKeyByField(item, itemKeyField)}
|
||||
renderCard={renderCard}
|
||||
cardContainerClassName={cardContainerClassName}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<div className={cn(gridClassName, cardContainerClassName)}>
|
||||
{items.slice(0, GRID_DISPLAY_LIMIT).map(item => (
|
||||
<div key={getItemKeyByField(item, itemKeyField)}>
|
||||
{renderCard(item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className={cn(gridClassName, cardContainerClassName)}>
|
||||
{items.slice(0, GRID_DISPLAY_LIMIT).map(item => (
|
||||
<div key={getItemKeyByField(item, itemKeyField)}>
|
||||
{renderCard(item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import type { Template } from '../types'
|
|||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import Empty from '../empty'
|
||||
import CardWrapper from './card-wrapper'
|
||||
import { GRID_CLASS } from './collection-list'
|
||||
import { GRID_CLASS } from './collection-constants'
|
||||
import TemplateCard from './template-card'
|
||||
|
||||
type PluginsVariant = {
|
||||
|
|
@ -44,7 +44,7 @@ const FlatList = (props: FlatListProps) => {
|
|||
<div className={GRID_CLASS}>
|
||||
{items.map(template => (
|
||||
<TemplateCard
|
||||
key={template.template_id}
|
||||
key={template.id}
|
||||
template={template}
|
||||
/>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import type { PluginCollection } from '../types'
|
|||
import { cn } from '@/utils/classnames'
|
||||
import Empty from '../empty'
|
||||
import CardWrapper from './card-wrapper'
|
||||
import { GRID_CLASS } from './collection-list'
|
||||
import { GRID_CLASS } from './collection-constants'
|
||||
import ListWithCollection from './list-with-collection'
|
||||
|
||||
type ListProps = {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
import type { PluginCollection, Template, TemplateCollection } from '../types'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import CardWrapper from './card-wrapper'
|
||||
import CollectionList, { CAROUSEL_COLLECTION_NAMES } from './collection-list'
|
||||
import { CAROUSEL_COLLECTION_NAMES } from './collection-constants'
|
||||
import CollectionList from './collection-list'
|
||||
import TemplateCard from './template-card'
|
||||
|
||||
type BaseProps = {
|
||||
|
|
@ -71,7 +72,7 @@ const ListWithCollection = (props: ListWithCollectionProps) => {
|
|||
<CollectionList
|
||||
collections={collections}
|
||||
collectionItemsMap={collectionItemsMap}
|
||||
itemKeyField="template_id"
|
||||
itemKeyField="id"
|
||||
renderCard={renderTemplateCard}
|
||||
carouselCollectionNames={[CAROUSEL_COLLECTION_NAMES.featured]}
|
||||
viewMoreSearchTab="templates"
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ describe('ListWrapper flat rendering', () => {
|
|||
|
||||
it('renders template flat list when template items exist', () => {
|
||||
mockMarketplaceData.creationType = 'templates'
|
||||
mockMarketplaceData.templates = [{ template_id: 't1' } as Template]
|
||||
mockMarketplaceData.templates = [{ id: 't1' } as Template]
|
||||
|
||||
render(<ListWrapper />)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
'use client'
|
||||
|
||||
import type { Template } from '../types'
|
||||
import { useLocale } from '#i18n'
|
||||
import { useLocale, useTranslation } from '#i18n'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { getLanguage } from '@/i18n-config/language'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { getIconFromMarketPlace } from '@/utils/get-icon'
|
||||
import { formatUsedCount } from '@/utils/template'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
|
||||
|
|
@ -17,7 +18,7 @@ type TemplateCardProps = {
|
|||
}
|
||||
|
||||
// Number of tag icons to show before showing "+X"
|
||||
const MAX_VISIBLE_TAGS = 7
|
||||
const MAX_VISIBLE_DEPS_PLUGINS = 7
|
||||
|
||||
// Soft background color palette for avatar
|
||||
const AVATAR_BG_COLORS = [
|
||||
|
|
@ -49,8 +50,9 @@ const TemplateCardComponent = ({
|
|||
className,
|
||||
}: TemplateCardProps) => {
|
||||
const locale = useLocale()
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
const { template_id, name, description, icon, tags, author, used_count, icon_background } = template as Template & { used_count?: number, icon_background?: string }
|
||||
const { id, template_name, overview, icon, publisher_handle, usage_count, icon_background, deps_plugins } = template
|
||||
const isIconUrl = !!icon && /^(?:https?:)?\/\//.test(icon)
|
||||
|
||||
const avatarBgStyle = useMemo(() => {
|
||||
|
|
@ -64,24 +66,23 @@ const TemplateCardComponent = ({
|
|||
// Only use class-based color if no inline style
|
||||
if (icon_background)
|
||||
return ''
|
||||
return getAvatarBgClass(template_id)
|
||||
}, [icon_background, template_id])
|
||||
|
||||
const descriptionText = description[getLanguage(locale)] || description.en_US || ''
|
||||
return getAvatarBgClass(id)
|
||||
}, [icon_background, id])
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
const url = getMarketplaceUrl(`/templates/${author}/${name}`, {
|
||||
const url = getMarketplaceUrl(`/templates/${publisher_handle}/${template_name}`, {
|
||||
theme,
|
||||
language: locale,
|
||||
templateId: template_id,
|
||||
templateId: id,
|
||||
creationType: 'templates',
|
||||
})
|
||||
window.open(url, '_blank')
|
||||
}, [author, name, theme, locale, template_id])
|
||||
}, [publisher_handle, template_name, theme, locale, id])
|
||||
|
||||
const visibleTags = tags?.slice(0, MAX_VISIBLE_TAGS) || []
|
||||
const remainingTagsCount = tags ? Math.max(0, tags.length - MAX_VISIBLE_TAGS) : 0
|
||||
const visibleDepsPlugins = deps_plugins?.slice(0, MAX_VISIBLE_DEPS_PLUGINS) || []
|
||||
const remainingDepsPluginsCount = deps_plugins ? Math.max(0, deps_plugins.length - MAX_VISIBLE_DEPS_PLUGINS) : 0
|
||||
|
||||
const formattedUsedCount = formatUsedCount(used_count, { precision: 0, rounding: 'floor' })
|
||||
const formattedUsedCount = formatUsedCount(usage_count, { precision: 0, rounding: 'floor' })
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -105,7 +106,7 @@ const TemplateCardComponent = ({
|
|||
? (
|
||||
<Image
|
||||
src={icon}
|
||||
alt={name}
|
||||
alt={template_name}
|
||||
width={24}
|
||||
height={24}
|
||||
className="h-6 w-6 object-contain"
|
||||
|
|
@ -117,22 +118,24 @@ const TemplateCardComponent = ({
|
|||
</div>
|
||||
{/* Title */}
|
||||
<div className="flex min-w-0 flex-1 flex-col justify-center gap-0.5">
|
||||
<p className="system-md-medium truncate text-text-primary">{name}</p>
|
||||
<p className="system-md-medium truncate text-text-primary">{template_name}</p>
|
||||
<div className="system-xs-regular flex items-center gap-2 text-text-tertiary">
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<span>by</span>
|
||||
<span className="truncate">{author}</span>
|
||||
<span className="shrink-0">{t('marketplace.templateCard.by', { ns: 'plugin' })}</span>
|
||||
<Link
|
||||
href={`/creators/${publisher_handle}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="truncate hover:text-text-secondary hover:underline"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{publisher_handle}
|
||||
</Link>
|
||||
</span>
|
||||
<span className="shrink-0">·</span>
|
||||
<span className="shrink-0">
|
||||
{t('usedCount', { ns: 'plugin', num: formattedUsedCount || 0 })}
|
||||
</span>
|
||||
{formattedUsedCount && (
|
||||
<>
|
||||
<span className="shrink-0">·</span>
|
||||
<span className="shrink-0">
|
||||
{formattedUsedCount}
|
||||
{' '}
|
||||
used
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -141,30 +144,34 @@ const TemplateCardComponent = ({
|
|||
<div className="shrink-0 px-4 pb-2 pt-1">
|
||||
<p
|
||||
className="system-xs-regular line-clamp-2 min-h-[32px] text-text-secondary"
|
||||
title={descriptionText}
|
||||
title={overview}
|
||||
>
|
||||
{descriptionText}
|
||||
{overview}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Bottom Info Bar - Tags as icons */}
|
||||
<div className="mt-auto flex min-h-7 shrink-0 items-center gap-1 px-4 py-1">
|
||||
{tags && tags.length > 0 && (
|
||||
{deps_plugins && deps_plugins.length > 0 && (
|
||||
<>
|
||||
{visibleTags.map((tag, index) => (
|
||||
{visibleDepsPlugins.map((depsPlugin, index) => (
|
||||
<div
|
||||
key={`${template_id}-tag-${index}`}
|
||||
key={`${id}-depsPlugin-${index}`}
|
||||
className="flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded-md border-[0.5px] border-effects-icon-border bg-background-default-dodge"
|
||||
title={tag}
|
||||
title={depsPlugin}
|
||||
>
|
||||
<span className="text-sm">{tag}</span>
|
||||
<img
|
||||
className="h-full w-full object-cover"
|
||||
src={getIconFromMarketPlace(depsPlugin)}
|
||||
alt={depsPlugin}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{remainingTagsCount > 0 && (
|
||||
{remainingDepsPluginsCount > 0 && (
|
||||
<div className="flex items-center justify-center p-0.5">
|
||||
<span className="system-xs-regular text-text-tertiary">
|
||||
+
|
||||
{remainingTagsCount}
|
||||
{remainingDepsPluginsCount}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,18 @@
|
|||
import type { Creator, Template } from '../../types'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import type { Locale } from '@/i18n-config/language'
|
||||
import { useLocale, useTranslation } from '#i18n'
|
||||
import { useTranslation } from '#i18n'
|
||||
import { RiArrowRightLine } from '@remixicon/react'
|
||||
import { Fragment } from 'react'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { useCategories } from '@/app/components/plugins/hooks'
|
||||
import { useRenderI18nObject } from '@/hooks/use-i18n'
|
||||
import { getLanguage } from '@/i18n-config/language'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { formatUsedCount } from '@/utils/template'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
import { MARKETPLACE_TYPE_ICON_COMPONENTS } from '../../plugin-type-icons'
|
||||
import { getCreatorAvatarUrl, getPluginDetailLinkInMarketplace } from '../../utils'
|
||||
|
||||
const DROPDOWN_PANEL = 'w-[472px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-xl backdrop-blur-sm'
|
||||
const DROPDOWN_PANEL = 'w-[472px] max-h-[710px] overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-xl backdrop-blur-sm'
|
||||
const ICON_BOX_BASE = 'flex shrink-0 items-center justify-center overflow-hidden border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge'
|
||||
|
||||
const SectionDivider = () => (
|
||||
|
|
@ -87,7 +86,6 @@ const SearchDropdown = ({
|
|||
isLoading = false,
|
||||
}: SearchDropdownProps) => {
|
||||
const { t } = useTranslation()
|
||||
const locale = useLocale()
|
||||
const getValueFromI18nObject = useRenderI18nObject()
|
||||
const { categoriesMap } = useCategories(true)
|
||||
|
||||
|
|
@ -101,7 +99,6 @@ const SearchDropdown = ({
|
|||
<TemplatesSection
|
||||
key="templates"
|
||||
templates={templates}
|
||||
locale={locale}
|
||||
t={t}
|
||||
/>,
|
||||
)
|
||||
|
|
@ -168,22 +165,23 @@ const SearchDropdown = ({
|
|||
|
||||
/* ---------- Templates Section ---------- */
|
||||
|
||||
function TemplatesSection({ templates, locale, t }: {
|
||||
function TemplatesSection({ templates, t }: {
|
||||
templates: Template[]
|
||||
locale: Locale
|
||||
t: ReturnType<typeof useTranslation>['t']
|
||||
}) {
|
||||
return (
|
||||
<DropdownSection title={t('templates', { ns: 'plugin' })}>
|
||||
{templates.map((template) => {
|
||||
const descriptionText = template.description[getLanguage(locale)] || template.description.en_US || ''
|
||||
const descriptionText = template.overview
|
||||
const formattedUsedCount = formatUsedCount(template.usage_count, { precision: 0, rounding: 'floor' })
|
||||
const usedLabel = t('usedCount', { ns: 'plugin', num: formattedUsedCount || 0 })
|
||||
const iconBgStyle = template.icon_background
|
||||
? { backgroundColor: template.icon_background }
|
||||
: undefined
|
||||
return (
|
||||
<DropdownItem
|
||||
key={template.template_id}
|
||||
href={getMarketplaceUrl(`/templates/${template.template_id}`)}
|
||||
key={template.id}
|
||||
href={getMarketplaceUrl(`/templates/${template.publisher_handle}/${template.template_name}`, { templateId: template.id })}
|
||||
icon={(
|
||||
<div className="flex shrink-0 items-start py-1">
|
||||
<IconBox shape="rounded-lg" style={iconBgStyle}>
|
||||
|
|
@ -192,16 +190,14 @@ function TemplatesSection({ templates, locale, t }: {
|
|||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="system-md-medium truncate text-text-primary">{template.name}</div>
|
||||
<div className="system-md-medium truncate text-text-primary">{template.template_name}</div>
|
||||
{!!descriptionText && (
|
||||
<div className="system-xs-regular line-clamp-2 text-text-tertiary">{descriptionText}</div>
|
||||
)}
|
||||
<ItemMeta
|
||||
items={[
|
||||
t('marketplace.searchDropdown.byAuthor', { ns: 'plugin', author: template.author }),
|
||||
...(template.tags.length > 0
|
||||
? [<span key="tags" className="system-xs-regular truncate">{template.tags.join(', ')}</span>]
|
||||
: []),
|
||||
t('marketplace.searchDropdown.byAuthor', { ns: 'plugin', author: template.publisher_handle }),
|
||||
usedLabel,
|
||||
]}
|
||||
/>
|
||||
</DropdownItem>
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ const SearchPage = () => {
|
|||
return (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{toShow.map(template => (
|
||||
<div key={template.template_id}>
|
||||
<div key={template.id}>
|
||||
<TemplateCard template={template} />
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -89,10 +89,11 @@ export function useTemplatesMarketplaceData(enabled = true) {
|
|||
const queryParams = useMemo((): TemplateSearchParams | undefined => {
|
||||
if (!isSearchMode)
|
||||
return undefined
|
||||
const sortBy = sort.sortBy === 'install_count' ? 'usage_count' : sort.sortBy === 'version_updated_at' ? 'updated_at' : sort.sortBy
|
||||
return {
|
||||
query: searchText,
|
||||
categories: activeTemplateCategory === CATEGORY_ALL ? undefined : [activeTemplateCategory],
|
||||
sort_by: sort.sortBy,
|
||||
sort_by: sortBy,
|
||||
sort_order: sort.sortOrder,
|
||||
}
|
||||
}, [isSearchMode, searchText, activeTemplateCategory, sort])
|
||||
|
|
|
|||
|
|
@ -72,13 +72,23 @@ export type TemplateCollection = {
|
|||
}
|
||||
|
||||
export type Template = {
|
||||
template_id: string
|
||||
name: string
|
||||
description: Record<string, string>
|
||||
id: string
|
||||
index_id: string
|
||||
template_name: string
|
||||
icon: string
|
||||
icon_background?: string
|
||||
tags: string[]
|
||||
author: string
|
||||
icon_file_key: string
|
||||
categories: string[]
|
||||
overview: string
|
||||
readme: string
|
||||
partner_link: string
|
||||
deps_plugins: string[]
|
||||
preferred_languages: string[]
|
||||
publisher_handle: string
|
||||
publisher_type: string
|
||||
kind: string
|
||||
status: string
|
||||
usage_count: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
|
@ -150,25 +160,12 @@ export type SyncCreatorProfileRequest = {
|
|||
status?: 'active' | 'inactive'
|
||||
}
|
||||
|
||||
// Template Detail (full template info from API)
|
||||
export type TemplateDetail = {
|
||||
id: string
|
||||
publisher_type: 'individual' | 'organization'
|
||||
// Template Detail (full template info from API, extends Template with extra fields)
|
||||
export type TemplateDetail = Template & {
|
||||
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 = {
|
||||
|
|
@ -202,25 +199,8 @@ export type UnifiedPluginItem = Plugin & {
|
|||
index_id: string
|
||||
}
|
||||
|
||||
// Template item shape from /search/unified (differs from TemplateDetail)
|
||||
export type UnifiedTemplateItem = {
|
||||
id: string
|
||||
index_id: string
|
||||
template_name: string
|
||||
icon: string
|
||||
icon_background?: string
|
||||
icon_file_key: string
|
||||
categories: string[]
|
||||
overview: string
|
||||
readme: string
|
||||
partner_link: string
|
||||
publisher_handle: string
|
||||
publisher_type: 'individual' | 'organization'
|
||||
status: string
|
||||
usage_count: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
// Template item shape from /search/unified (same as Template)
|
||||
export type UnifiedTemplateItem = Template
|
||||
|
||||
// Creator item shape from /search/unified (superset of Creator with index_id)
|
||||
export type UnifiedCreatorItem = Creator & {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import type {
|
|||
UnifiedPluginItem,
|
||||
UnifiedSearchParams,
|
||||
UnifiedSearchResponse,
|
||||
UnifiedTemplateItem,
|
||||
} from '@/app/components/plugins/marketplace/types'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
|
|
@ -28,7 +27,7 @@ type MarketplaceFetchOptions = {
|
|||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/** Get a string key from an item by field name (e.g. plugin_id, template_id). */
|
||||
/** Get a string key from an item by field name (e.g. plugin_id, id). */
|
||||
export function getItemKeyByField<T>(item: T, field: keyof T): string {
|
||||
return String((item as Record<string, unknown>)[field as string])
|
||||
}
|
||||
|
|
@ -133,20 +132,11 @@ export const getMarketplaceCollectionsAndPlugins = async (
|
|||
}
|
||||
|
||||
export function mapTemplateDetailToTemplate(template: TemplateDetail): Template {
|
||||
const descriptionText = template.overview || template.readme || ''
|
||||
// TemplateDetail extends Template; just override publisher_handle from the detail-specific field
|
||||
return {
|
||||
template_id: template.id,
|
||||
name: template.template_name,
|
||||
description: {
|
||||
en_US: descriptionText,
|
||||
zh_Hans: descriptionText,
|
||||
},
|
||||
icon: template.icon || '',
|
||||
icon_background: template.icon_background || undefined,
|
||||
tags: template.categories || [],
|
||||
author: template.publisher_unique_handle || template.creator_email || '',
|
||||
created_at: template.created_at,
|
||||
updated_at: template.updated_at,
|
||||
...template,
|
||||
publisher_handle: template.publisher_handle || template.publisher_unique_handle || template.creator_email || '',
|
||||
index_id: template.index_id || template.id,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -437,24 +427,10 @@ export function mapUnifiedPluginToPlugin(item: UnifiedPluginItem): Plugin {
|
|||
}
|
||||
|
||||
/**
|
||||
* Map unified search template item to Template type
|
||||
* Map unified search template item to Template type (identity since UnifiedTemplateItem = Template)
|
||||
*/
|
||||
export function mapUnifiedTemplateToTemplate(item: UnifiedTemplateItem): Template {
|
||||
const descriptionText = item.overview || item.readme || ''
|
||||
return {
|
||||
template_id: item.id,
|
||||
name: item.template_name,
|
||||
description: {
|
||||
en_US: descriptionText,
|
||||
zh_Hans: descriptionText,
|
||||
},
|
||||
icon: item.icon || '',
|
||||
icon_background: item.icon_background || undefined,
|
||||
tags: item.categories || [],
|
||||
author: item.publisher_handle || '',
|
||||
created_at: item.created_at,
|
||||
updated_at: item.updated_at,
|
||||
}
|
||||
export function mapUnifiedTemplateToTemplate(item: Template): Template {
|
||||
return item
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -2820,16 +2820,6 @@
|
|||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/plugins/marketplace/list/card-wrapper.tsx": {
|
||||
"tailwindcss/no-unnecessary-whitespace": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/plugins/marketplace/list/list-with-collection.tsx": {
|
||||
"tailwindcss/no-unnecessary-whitespace": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/plugins/marketplace/sort-dropdown/index.spec.tsx": {
|
||||
"unused-imports/no-unused-vars": {
|
||||
"count": 1
|
||||
|
|
|
|||
|
|
@ -221,16 +221,17 @@
|
|||
"marketplace.sortOption.mostPopular": "Most Popular",
|
||||
"marketplace.sortOption.newlyReleased": "Newly Released",
|
||||
"marketplace.sortOption.recentlyUpdated": "Recently Updated",
|
||||
"marketplace.templatesHeroSubtitle": "Community-built workflow templates — ready to use, remix, and deploy.",
|
||||
"marketplace.templatesHeroTitle": "Create. Remix. Deploy.",
|
||||
"marketplace.templateCard.by": "By",
|
||||
"marketplace.templateCategory.all": "All",
|
||||
"marketplace.templateCategory.marketing": "Marketing",
|
||||
"marketplace.templateCategory.sales": "Sales",
|
||||
"marketplace.templateCategory.support": "Support",
|
||||
"marketplace.templateCategory.operations": "Operations",
|
||||
"marketplace.templateCategory.design": "Design",
|
||||
"marketplace.templateCategory.it": "IT",
|
||||
"marketplace.templateCategory.knowledge": "Knowledge",
|
||||
"marketplace.templateCategory.design": "Design",
|
||||
"marketplace.templateCategory.marketing": "Marketing",
|
||||
"marketplace.templateCategory.operations": "Operations",
|
||||
"marketplace.templateCategory.sales": "Sales",
|
||||
"marketplace.templateCategory.support": "Support",
|
||||
"marketplace.templatesHeroSubtitle": "Community-built workflow templates — ready to use, remix, and deploy.",
|
||||
"marketplace.templatesHeroTitle": "Create. Remix. Deploy.",
|
||||
"marketplace.verifiedTip": "Verified by Dify",
|
||||
"marketplace.viewMore": "View more",
|
||||
"metadata.title": "Plugins",
|
||||
|
|
@ -278,5 +279,6 @@
|
|||
"upgrade.title": "Install Plugin",
|
||||
"upgrade.upgrade": "Install",
|
||||
"upgrade.upgrading": "Installing...",
|
||||
"upgrade.usedInApps": "Used in {{num}} apps"
|
||||
"upgrade.usedInApps": "Used in {{num}} apps",
|
||||
"usedCount": "{{num}} used"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -221,16 +221,17 @@
|
|||
"marketplace.sortOption.mostPopular": "最受欢迎",
|
||||
"marketplace.sortOption.newlyReleased": "最新发布",
|
||||
"marketplace.sortOption.recentlyUpdated": "最近更新",
|
||||
"marketplace.templatesHeroSubtitle": "社区构建的工作流模板 —— 随时可使用、复刻和部署。",
|
||||
"marketplace.templatesHeroTitle": "创建。复刻。部署。",
|
||||
"marketplace.templateCard.by": "来自",
|
||||
"marketplace.templateCategory.all": "全部",
|
||||
"marketplace.templateCategory.marketing": "营销",
|
||||
"marketplace.templateCategory.sales": "销售",
|
||||
"marketplace.templateCategory.support": "支持",
|
||||
"marketplace.templateCategory.operations": "运营",
|
||||
"marketplace.templateCategory.design": "设计",
|
||||
"marketplace.templateCategory.it": "IT",
|
||||
"marketplace.templateCategory.knowledge": "知识",
|
||||
"marketplace.templateCategory.design": "设计",
|
||||
"marketplace.templateCategory.marketing": "营销",
|
||||
"marketplace.templateCategory.operations": "运营",
|
||||
"marketplace.templateCategory.sales": "销售",
|
||||
"marketplace.templateCategory.support": "支持",
|
||||
"marketplace.templatesHeroSubtitle": "社区构建的工作流模板 —— 随时可使用、复刻和部署。",
|
||||
"marketplace.templatesHeroTitle": "创建。复刻。部署。",
|
||||
"marketplace.verifiedTip": "此插件由 Dify 认证",
|
||||
"marketplace.viewMore": "查看更多",
|
||||
"metadata.title": "插件",
|
||||
|
|
@ -278,5 +279,6 @@
|
|||
"upgrade.title": "安装插件",
|
||||
"upgrade.upgrade": "安装",
|
||||
"upgrade.upgrading": "安装中...",
|
||||
"upgrade.usedInApps": "在 {{num}} 个应用中使用"
|
||||
"upgrade.usedInApps": "在 {{num}} 个应用中使用",
|
||||
"usedCount": "{{num}} 次使用"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue