feat: enhance templates marketplace with search functionality and template mapping

This commit is contained in:
yessenia
2026-02-05 18:30:49 +08:00
parent 96933e4349
commit ccaccc8b34
6 changed files with 216 additions and 57 deletions

View File

@ -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>

View File

@ -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>
)
}

View File

@ -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

View File

@ -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,

View File

@ -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] = []

View File

@ -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
}>(),
)