mirror of
https://github.com/langgenius/dify.git
synced 2026-02-22 19:15:47 +08:00
feat: enhance templates marketplace with search functionality and template mapping
This commit is contained in:
@ -12,6 +12,7 @@ import SortDropdown from '../sort-dropdown'
|
||||
import { useMarketplaceData } from '../state'
|
||||
import List from './index'
|
||||
import TemplateList from './template-list'
|
||||
import TemplateSearchList from './template-search-list'
|
||||
|
||||
type ListWrapperProps = {
|
||||
showInstallButton?: boolean
|
||||
@ -40,7 +41,12 @@ const ListWrapper = ({
|
||||
|
||||
// Templates view
|
||||
if (creationType === 'templates') {
|
||||
const { templateCollections, templateCollectionTemplatesMap } = marketplaceData
|
||||
const {
|
||||
templateCollections,
|
||||
templateCollectionTemplatesMap,
|
||||
templates,
|
||||
isSearchMode: isTemplateSearchMode,
|
||||
} = marketplaceData
|
||||
return (
|
||||
<div
|
||||
style={{ scrollbarGutter: 'stable' }}
|
||||
@ -55,10 +61,16 @@ const ListWrapper = ({
|
||||
}
|
||||
{
|
||||
!isLoading && (
|
||||
<TemplateList
|
||||
templateCollections={templateCollections || []}
|
||||
templateCollectionTemplatesMap={templateCollectionTemplatesMap || {}}
|
||||
/>
|
||||
isTemplateSearchMode
|
||||
? (
|
||||
<TemplateSearchList templates={templates || []} />
|
||||
)
|
||||
: (
|
||||
<TemplateList
|
||||
templateCollections={templateCollections || []}
|
||||
templateCollectionTemplatesMap={templateCollectionTemplatesMap || {}}
|
||||
/>
|
||||
)
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
@ -4,33 +4,112 @@ import type { Template } from '../types'
|
||||
import { useLocale } from '#i18n'
|
||||
import Image from 'next/image'
|
||||
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 { getMarketplaceUrl } from '@/utils/var'
|
||||
|
||||
type TemplateCardProps = {
|
||||
template: Template
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Number of tag icons to show before showing "+X"
|
||||
const MAX_VISIBLE_TAGS = 7
|
||||
|
||||
// Soft background color palette for avatar
|
||||
const AVATAR_BG_COLORS = [
|
||||
'bg-components-icon-bg-red-soft',
|
||||
'bg-components-icon-bg-orange-dark-soft',
|
||||
'bg-components-icon-bg-yellow-soft',
|
||||
'bg-components-icon-bg-green-soft',
|
||||
'bg-components-icon-bg-teal-soft',
|
||||
'bg-components-icon-bg-blue-light-soft',
|
||||
'bg-components-icon-bg-blue-soft',
|
||||
'bg-components-icon-bg-indigo-soft',
|
||||
'bg-components-icon-bg-violet-soft',
|
||||
'bg-components-icon-bg-pink-soft',
|
||||
]
|
||||
|
||||
// Simple hash function to get consistent color per template
|
||||
const getAvatarBgClass = (id: string): string => {
|
||||
let hash = 0
|
||||
for (let i = 0; i < id.length; i++) {
|
||||
const char = id.charCodeAt(i)
|
||||
hash = ((hash << 5) - hash) + char
|
||||
hash = hash & hash
|
||||
}
|
||||
return AVATAR_BG_COLORS[Math.abs(hash) % AVATAR_BG_COLORS.length]
|
||||
}
|
||||
|
||||
const TemplateCardComponent = ({
|
||||
template,
|
||||
className,
|
||||
}: TemplateCardProps) => {
|
||||
const locale = useLocale()
|
||||
const { name, description, icon, tags, author } = template
|
||||
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 isIconUrl = !!icon && /^(?:https?:)?\/\//.test(icon)
|
||||
|
||||
const avatarBgStyle = useMemo(() => {
|
||||
// If icon_background is provided (hex or rgba), use it directly
|
||||
if (icon_background)
|
||||
return { backgroundColor: icon_background }
|
||||
return undefined
|
||||
}, [icon_background])
|
||||
|
||||
const avatarBgClass = useMemo(() => {
|
||||
// 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 || ''
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
const url = getMarketplaceUrl(`/templates/${author}/${name}`, {
|
||||
theme,
|
||||
language: locale,
|
||||
templateId: template_id,
|
||||
})
|
||||
window.open(url, '_blank')
|
||||
}, [author, name, theme, locale, template_id])
|
||||
|
||||
const visibleTags = tags?.slice(0, MAX_VISIBLE_TAGS) || []
|
||||
const remainingTagsCount = tags ? Math.max(0, tags.length - MAX_VISIBLE_TAGS) : 0
|
||||
|
||||
// Format used count (e.g., 134000 -> "134k")
|
||||
const formatUsedCount = (count?: number) => {
|
||||
if (!count)
|
||||
return null
|
||||
if (count >= 1000)
|
||||
return `${Math.floor(count / 1000)}k`
|
||||
return String(count)
|
||||
}
|
||||
|
||||
const formattedUsedCount = formatUsedCount(used_count)
|
||||
|
||||
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,
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'hover-bg-components-panel-on-panel-item-bg relative flex h-full cursor-pointer flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg pb-3 shadow-xs',
|
||||
className,
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-background-default-lighter">
|
||||
{icon
|
||||
<div className="flex shrink-0 items-center gap-3 px-4 pb-2 pt-4">
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-[10px] border-[0.5px] border-divider-regular p-1',
|
||||
avatarBgClass,
|
||||
)}
|
||||
style={avatarBgStyle}
|
||||
>
|
||||
{isIconUrl
|
||||
? (
|
||||
<Image
|
||||
src={icon}
|
||||
@ -41,48 +120,65 @@ const TemplateCardComponent = ({
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<span className="text-lg">📄</span>
|
||||
<span className="text-2xl leading-[1.2]">{icon || '📄'}</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}
|
||||
{/* 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>
|
||||
<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>
|
||||
{formattedUsedCount && (
|
||||
<>
|
||||
<span className="shrink-0">·</span>
|
||||
<span className="shrink-0">
|
||||
{formattedUsedCount}
|
||||
{' '}
|
||||
used
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div
|
||||
className="system-xs-regular mt-3 line-clamp-2 text-text-tertiary"
|
||||
title={descriptionText}
|
||||
>
|
||||
{descriptionText}
|
||||
<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}
|
||||
>
|
||||
{descriptionText}
|
||||
</p>
|
||||
</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>
|
||||
)}
|
||||
{/* 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 && (
|
||||
<>
|
||||
{visibleTags.map((tag, index) => (
|
||||
<div
|
||||
key={`${template_id}-tag-${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}
|
||||
>
|
||||
<span className="text-sm">{tag}</span>
|
||||
</div>
|
||||
))}
|
||||
{remainingTagsCount > 0 && (
|
||||
<div className="flex items-center justify-center p-0.5">
|
||||
<span className="system-xs-regular text-text-tertiary">
|
||||
+
|
||||
{remainingTagsCount}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -0,0 +1,27 @@
|
||||
'use client'
|
||||
|
||||
import type { Template } from '../types'
|
||||
import Empty from '../empty'
|
||||
import TemplateCard from './template-card'
|
||||
|
||||
type TemplateSearchListProps = {
|
||||
templates: Template[]
|
||||
}
|
||||
|
||||
const TemplateSearchList = ({ templates }: TemplateSearchListProps) => {
|
||||
if (templates.length === 0) {
|
||||
return <Empty />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{templates.map(template => (
|
||||
<div key={template.template_id}>
|
||||
<TemplateCard template={template} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TemplateSearchList
|
||||
@ -6,7 +6,7 @@ import { useActivePluginType, useFilterPluginTags, useMarketplaceSearchMode, use
|
||||
import { PLUGIN_TYPE_SEARCH_MAP } from './constants'
|
||||
import { useMarketplaceContainerScroll } from './hooks'
|
||||
import { useMarketplaceCollectionsAndPlugins, useMarketplacePlugins, useMarketplaceTemplateCollectionsAndTemplates, useMarketplaceTemplates } from './query'
|
||||
import { getCollectionsParams, getMarketplaceListFilterType } from './utils'
|
||||
import { getCollectionsParams, getMarketplaceListFilterType, mapTemplateDetailToTemplate } from './utils'
|
||||
|
||||
/**
|
||||
* Hook for plugins marketplace data
|
||||
@ -122,13 +122,18 @@ export function useTemplatesMarketplaceData() {
|
||||
}
|
||||
}, [templateCollectionsQuery.data?.templateCollectionTemplatesMap])
|
||||
|
||||
const searchTemplates = useMemo(() => {
|
||||
const rawTemplates = templatesQuery.data?.pages.flatMap(page => page.templates) || []
|
||||
return rawTemplates.map(mapTemplateDetailToTemplate)
|
||||
}, [templatesQuery.data])
|
||||
|
||||
// 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),
|
||||
templates: searchTemplates,
|
||||
templatesTotal: templatesQuery.data?.pages[0]?.total,
|
||||
page: templatesQuery.data?.pages.length || 1,
|
||||
isLoading: templatesQuery.isLoading,
|
||||
|
||||
@ -116,6 +116,23 @@ export const getMarketplaceCollectionsAndPlugins = async (
|
||||
}
|
||||
}
|
||||
|
||||
export function mapTemplateDetailToTemplate(template: TemplateDetail): Template {
|
||||
const descriptionText = template.overview || template.readme || ''
|
||||
return {
|
||||
template_id: template.id,
|
||||
name: template.template_name,
|
||||
description: {
|
||||
en_US: descriptionText,
|
||||
zh_Hans: descriptionText,
|
||||
},
|
||||
icon: template.icon || '',
|
||||
tags: template.categories || [],
|
||||
author: template.publisher_unique_handle || template.creator_email || '',
|
||||
created_at: template.created_at,
|
||||
updated_at: template.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
export const getMarketplaceTemplateCollectionsAndTemplates = async (
|
||||
query?: { page?: number, page_size?: number, condition?: string },
|
||||
options?: MarketplaceFetchOptions,
|
||||
@ -133,7 +150,7 @@ export const getMarketplaceTemplateCollectionsAndTemplates = async (
|
||||
}, {
|
||||
signal: options?.signal,
|
||||
})
|
||||
templateCollections = res.data || []
|
||||
templateCollections = res.data?.collections || []
|
||||
|
||||
await Promise.all(templateCollections.map(async (collection) => {
|
||||
try {
|
||||
@ -141,7 +158,8 @@ export const getMarketplaceTemplateCollectionsAndTemplates = async (
|
||||
params: { collectionName: collection.name },
|
||||
body: { limit: 20 },
|
||||
}, { signal: options?.signal })
|
||||
templateCollectionTemplatesMap[collection.name] = (templatesRes.data || []) as Template[]
|
||||
const templatesData = templatesRes.data?.templates || []
|
||||
templateCollectionTemplatesMap[collection.name] = templatesData.map(mapTemplateDetailToTemplate)
|
||||
}
|
||||
catch {
|
||||
templateCollectionTemplatesMap[collection.name] = []
|
||||
|
||||
@ -10,7 +10,6 @@ import type {
|
||||
MarketplaceCollection,
|
||||
PluginsSearchParams,
|
||||
SyncCreatorProfileRequest,
|
||||
Template,
|
||||
TemplateCollection,
|
||||
TemplateDetail,
|
||||
TemplateSearchParams,
|
||||
@ -88,11 +87,13 @@ export const templateCollectionsContract = base
|
||||
)
|
||||
.output(
|
||||
type<{
|
||||
data?: TemplateCollection[]
|
||||
has_more?: boolean
|
||||
limit?: number
|
||||
page?: number
|
||||
total?: number
|
||||
data?: {
|
||||
collections?: TemplateCollection[]
|
||||
has_more?: boolean
|
||||
limit?: number
|
||||
page?: number
|
||||
total?: number
|
||||
}
|
||||
}>(),
|
||||
)
|
||||
|
||||
@ -151,7 +152,7 @@ export const getCollectionTemplatesContract = base
|
||||
)
|
||||
.output(
|
||||
type<{
|
||||
data?: Template[]
|
||||
data?: TemplatesListResponse
|
||||
}>(),
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user